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:
parent
1e6bf6425a
commit
8e2c4d49fa
5 changed files with 110 additions and 50 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" } } },
|
||||
]),
|
||||
],
|
||||
[],
|
||||
|
|
|
|||
Loading…
Reference in a new issue