chore: address Copilot review round 1 (#2226)

- Include `optionalDependencies` in the declared tier's candidate union. A
  peer range constrains the consumer's host; an optional range is a direct
  declaration npm attempts to install, so omitting it left an optional-only
  package free to skew unseen. `peerDependencies` stays excluded, with the
  reason stated.
- Make the end-to-end fixture's client copy UNDECLARED — only the root
  manifest names `solo`, while `clients/web` merely holds a top-level copy.
  That is #2226's defining shape, and it is what makes the candidate set a
  union across installs rather than a per-install intersection; the old
  fixture would have passed a per-install implementation. Adds the mirror
  case (declared only by a client, held by the root) so the union is pinned
  in both directions.
- Update the two docs that still described the single-tier guard:
  `.claude/skills/local-dev/SKILL.md` said "nothing gates either of those,
  and `verify:dep-lockstep` is not it" and cited the live `@types/node`
  drift in the present tense; `docs/quality-gate.md` described only the
  program tier. Both now state what each tier covers and the two limits
  that remain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-05 16:09:44 -04:00
commit 8e2c4d49fa
5 changed files with 110 additions and 50 deletions

View file

@ -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 <pkg>` there — it moves the lockfile
within the declared range without widening the range, which
`npm install <pkg>@<version>` 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`

View file

@ -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). |

View file

@ -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

View file

@ -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;
}

View file

@ -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" } } },
]),
],
[],