From 6c33da38c964ae38b4b4e28f8c33fc379951166d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 10 Aug 2026 20:29:58 -0400 Subject: [PATCH] chore(deps): align zod across installs and guard against version skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1896 `clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The cause is not a zod regression nor a pathological inference site of ours: it is two copies of zod at *mismatched versions* inside one tsc program. `clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files resolve zod (and the SDK's zod) from the **root** install, alongside web's own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs 4.4.3 TypeScript must relate two structurally-distinct declarations of every `@modelcontextprotocol/*` schema type, which is exponential over a recursive generic surface — `TS2589 Type instantiation is excessively deep`, then OOM. Bumping root zod to 4.4.3 and changing nothing else returns the build to baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at 4.3.6. Two copies at the same version are harmless; skew is what explodes. So drop the `~4.3.6` hold on `clients/web` and move all four manifests and lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate` to keep them there. The guard derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and fails deny-by-default on any skew not in the annotated `TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so dropping it from `validate` is caught by `verify:format-coverage`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 1 + AGENTS.md | 3 +- README.md | 7 +- clients/cli/package-lock.json | 2 +- clients/cli/package.json | 2 +- clients/tui/package-lock.json | 2 +- clients/tui/package.json | 2 +- clients/web/package-lock.json | 8 +- clients/web/package.json | 2 +- package-lock.json | 8 +- package.json | 5 +- scripts/verify-dep-lockstep.mjs | 280 +++++++++++++++++++++++++++ scripts/verify-dep-lockstep.test.mjs | 196 +++++++++++++++++++ scripts/verify-format-coverage.mjs | 11 +- 14 files changed, 505 insertions(+), 24 deletions(-) create mode 100644 scripts/verify-dep-lockstep.mjs create mode 100644 scripts/verify-dep-lockstep.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd1..a49cec61 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. +- **A dependency bump must land in every install.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index a4185baa..1d38a6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,11 +448,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no install-crossing dependency resolves to two different versions), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), reads the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 13792764..635d93cf 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ inspector/ │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests -├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify) +├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify) ├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) @@ -263,13 +263,14 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `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), plus two headless-Chromium 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. | | `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 ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. 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 test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. 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 `core/` and `test-servers/src` — which resolve 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 `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e4..926899e7 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -19,7 +19,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-cli": "build/index.js" diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f3..7dfbad9c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -43,7 +43,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3e..431b60d8 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -20,7 +20,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-tui": "build/index.js" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc..b2ffcc92 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -39,7 +39,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7..11cfb098 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -35,7 +35,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "~4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-web": "build/index.js" @@ -11677,9 +11677,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/clients/web/package.json b/clients/web/package.json index a347376e..d15870e6 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -61,7 +61,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "~4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", diff --git a/package-lock.json b/package-lock.json index 58f21e7a..4766aff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector": "clients/launcher/build/index.js" @@ -4534,9 +4534,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index d3467cf6..0d353453 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", "validate:core": "npm run format:check:core && npm run format:check:scripts && npm run format:check:shared && npm run lint:core && npm run lint:shared", "lint:core": "eslint \"core/**/*.{ts,tsx}\"", "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js", @@ -98,7 +99,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs new file mode 100644 index 00000000..496fdc8d --- /dev/null +++ b/scripts/verify-dep-lockstep.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +// Durable guard for the "one version per install-crossing dependency" invariant +// (#1896). v2 is not an npm workspace: the root and each `clients/*` carry their +// own `node_modules`, so the *same* package can resolve to two different +// versions in one process — or, worse, in one `tsc` program. +// +// That second case is what this guard exists for. A client's +// `tsconfig.test.json` compiles first-party sources that live *outside* the +// client (`test-servers/src`, `core/`), and those files resolve their +// dependencies from the **root** install while the client's own sources resolve +// from the client install. When the two copies are the same version the +// duplication is harmless; when they skew, TypeScript must relate two +// structurally-distinct declarations of the same type. +// +// For most packages that is merely redundant work. For a deeply +// recursive-generic type surface it is exponential: zod `4.3.6` (root) against +// zod `4.4.3` (clients/web) made `tsc -b` in `clients/web` exhaust the 4GB +// default heap outright (`FATAL ERROR: Ineffective mark-compacts near heap +// limit`) via `TS2589 Type instantiation is excessively deep`, because every +// `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the +// two copies — changing nothing else — returned the build to its baseline cost. +// +// The candidate set is DERIVED, not hand-listed: it is the packages imported by +// `core/` and `test-servers/src`, the two first-party surfaces compiled into +// more than one client's program. Skew is then denied by default, with a small +// allowlist of packages verified to tolerate it (below). A dependency that +// starts skewing therefore fails `validate` and forces a decision, rather than +// surfacing months later as an unexplained OOM. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { rootReachesScript } from "./lib/npm-scripts.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +// The first-party source trees that are compiled into more than one install's +// `tsc` program, and so define which dependencies can appear twice in one +// program. `core/` is consumed by every client via the `@inspector/core` alias; +// `test-servers/src` is pulled into the web and cli test projects. +const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; + +// Packages whose cross-install skew is verified benign, each with the reason. +// This is an allowlist of *names*, not of version pairs, so an ordinary patch +// float within one of these does not churn the file — while any package NOT +// listed here failing the check is a genuine, unreviewed new skew. +// +// The admission test is the one the zod incident established: does the +// package's public type surface consist of deeply recursive generics that +// first-party code relates across the boundary? If yes it must stay in +// lockstep; if no, a patch-level difference costs nothing. +const TOLERATED_SKEW = new Map([ + [ + "react", + "Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.", + ], + [ + "hono", + "Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.", + ], + [ + "jose", + "Consumed as flat function calls in `core/auth`; no generic type flows between installs.", + ], + [ + "@modelcontextprotocol/ext-apps", + "Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.", + ], +]); + +/** Package names that are Node built-ins (with or without the `node:` prefix). */ +const BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), +]); + +// A bare package specifier: optional `@scope/`, then a name, then any subpath. +// Anchored so prose that happens to sit after the word `from` in a comment +// ("from cwd omitted") cannot be mistaken for an import. +const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/; + +/** + * The bare package name a module specifier resolves to — `@scope/name` or + * `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for + * relative paths, built-ins, URLs, and anything not shaped like a specifier. + */ +export function packageNameOf(specifier) { + if (typeof specifier !== "string" || specifier === "") return null; + if (specifier.startsWith(".") || specifier.startsWith("/")) return null; + if (BUILTINS.has(specifier)) return null; + const m = PACKAGE_SPECIFIER.exec(specifier); + if (!m) return null; + const name = m[1] ? `${m[1]}/${m[2]}` : m[2]; + // A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package. + if (name.includes(":")) return null; + return name; +} + +// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic +// `import("x")`. Only these three forms introduce a dependency's *types*. +const SPECIFIER_FORMS = [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s+["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +/** + * Every third-party package name imported by a blob of TypeScript source. + * Deliberately a regex scan rather than a parse: it only needs to over- rather + * than under-approximate, since a name absent from every lockfile contributes + * nothing downstream (`@inspector/core` is a build-time alias, not a package, + * and drops out that way). + */ +export function importedPackageNames(source) { + const names = new Set(); + for (const re of SPECIFIER_FORMS) { + for (const m of source.matchAll(re)) { + const name = packageNameOf(m[1]); + if (name) names.add(name); + } + } + return names; +} + +/** + * Top-level installed versions of every package in a parsed lockfile, keyed by + * package name. Only `node_modules/` entries count — a *nested* + * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside + * one install, which is routine and not what this guard is about. + */ +export function topLevelLockVersions(lock) { + const versions = new Map(); + for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) { + const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath); + if (!m || typeof entry?.version !== "string") continue; + versions.set(m[1], entry.version); + } + return versions; +} + +/** + * Find candidate packages that resolve to more than one version across the + * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per + * skewed package, sorted by name, each listing the version each install holds. + * Packages present in fewer than two installs cannot skew and are skipped. + */ +export function findSkew(candidates, installs) { + const skewed = []; + for (const name of [...candidates].sort()) { + const holders = installs + .filter(({ versions }) => versions.has(name)) + .map(({ dir, versions }) => ({ dir, version: versions.get(name) })); + if (holders.length < 2) continue; + const distinct = new Set(holders.map((h) => h.version)); + if (distinct.size > 1) skewed.push({ name, holders }); + } + return skewed; +} + +/** Split skewed packages into the tolerated ones and the failures. */ +export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + return { + failures: skewed.filter((s) => !tolerated.has(s.name)), + ignored: skewed.filter((s) => tolerated.has(s.name)), + }; +} + +/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +function sharedSourceFiles() { + const out = execFileSync( + "git", + ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + { cwd: repoRoot, encoding: "utf8" }, + ); + return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); +} + +/** + * The installs to compare: the repo root plus every `clients/*` that carries a + * lockfile. Discovered from disk rather than listed, so a new client is covered + * without editing this guard (the same enrollment style as + * `verify:typecheck-coverage`). + */ +function installDirs() { + const clientsDir = path.join(repoRoot, "clients"); + const clients = existsSync(clientsDir) + ? readdirSync(clientsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `clients/${e.name}`) + .sort() + : []; + return ["."] + .concat(clients) + .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); +} + +/** + * Run the guard. Prints its verdict and `process.exit(1)`s on any failure. + * Called only when this file is executed directly — importing it (for tests) + * gives access to the pure helpers above without running any of this. + */ +export function main() { + const rootScripts = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ).scripts; + + // Vouch for a sibling guard: a guard cannot detect being unrun itself, but the + // three can vouch for one another, so dropping any single one from `validate` + // is caught by another. `verify:format-coverage` vouches for this one in turn. + if (!rootReachesScript(rootScripts, "verify:format-coverage")) { + console.error( + "verify:dep-lockstep — the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.", + ); + process.exit(1); + } + + const files = sharedSourceFiles(); + if (files.length === 0) { + console.error( + `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + ); + process.exit(1); + } + + const candidates = new Set(); + for (const file of files) { + const source = readFileSync(path.join(repoRoot, file), "utf8"); + for (const name of importedPackageNames(source)) candidates.add(name); + } + + const dirs = installDirs(); + const installs = dirs.map((dir) => ({ + dir, + versions: topLevelLockVersions( + JSON.parse( + readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), + ), + ), + })); + + const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); + + if (failures.length > 0) { + console.error( + `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, + ); + for (const { name, holders } of failures) { + console.error(` ${name}`); + for (const { dir, version } of holders) + console.error(` ${version} (${dir})`); + } + console.error( + "\nThese packages' types are compiled into a single `tsc` program from two installs" + + `\n(${SHARED_SOURCE_DIRS.join(" and ")} resolve from the root, a client's own sources from the client),` + + "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", + ); + console.error( + "\nAlign them — `npm install @` in each install so all lockfiles agree — or, if this" + + "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + + "\nwith the reason. See AGENTS.md.", + ); + process.exit(1); + } + + const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; + console.log( + `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`, + ); +} + +// Run only when executed directly (`node scripts/verify-dep-lockstep.mjs`); +// importing this file (tests) exposes the pure helpers without running the guard. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs new file mode 100644 index 00000000..6e36d255 --- /dev/null +++ b/scripts/verify-dep-lockstep.test.mjs @@ -0,0 +1,196 @@ +// Table-driven tests for the pure helpers of the dep-lockstep guard (#1896). +// One case per rule the guard encodes — the comment names the rule, so a future +// change that relaxes one is visible as a deleted assertion rather than a quiet +// behavior shift. Run via `npm run test:scripts` (node:test; the root has no +// vitest harness). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + findSkew, + importedPackageNames, + packageNameOf, + partitionSkew, + topLevelLockVersions, +} from "./verify-dep-lockstep.mjs"; + +test("packageNameOf: bare names, scopes, and subpaths", () => { + const cases = [ + ["zod", "zod"], + ["zod/v4", "zod"], // subpath dropped — one package, one version + ["@modelcontextprotocol/client", "@modelcontextprotocol/client"], + ["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"], + ["react-dom/client", "react-dom"], + ]; + for (const [input, expected] of cases) + assert.equal(packageNameOf(input), expected, input); +}); + +test("packageNameOf: non-packages are rejected", () => { + // Relative/absolute paths, built-ins with and without the `node:` prefix, + // protocol specifiers, and prose that follows the word `from` in a comment. + const rejected = [ + "./foo", + "../core/mcp", + "/abs/path", + "fs", + "path", + "node:crypto", + "node:test", + "file:", + "data:text/plain,x", + "cwd omitted", + "", + ]; + for (const input of rejected) + assert.equal(packageNameOf(input), null, JSON.stringify(input)); + assert.equal(packageNameOf(undefined), null); +}); + +test("importedPackageNames: the three specifier forms that introduce types", () => { + const source = ` + import { z } from "zod/v4"; + export type { Foo } from '@modelcontextprotocol/core'; + import "./side-effect.css"; + import "pino"; + const mod = await import("chokidar"); + import fs from "node:fs"; + import { helper } from "../local/helper"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@modelcontextprotocol/core", + "chokidar", + "pino", + "zod", + ]); +}); + +test("importedPackageNames: prose after `from` is not an import", () => { + // The scan over-approximates on purpose, but must not invent package names + // out of comment text — a bogus name absent from every lockfile is inert, + // yet a *plausible* one would silently widen the candidate set. + const source = ` + // Resolved relative to the runner dir, not from "cwd omitted" by the caller. + /** Reads the manifest from "file:" URLs. */ + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + +test("topLevelLockVersions: nested duplicates are ignored", () => { + // A nested `node_modules/a/node_modules/b` is npm resolving a transitive + // conflict *inside* one install — routine, and not the cross-install skew + // this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2 + // is the live example). + const lock = { + packages: { + "": { name: "root" }, + "node_modules/zod": { version: "4.4.3" }, + "node_modules/yaml": { version: "2.9.0" }, + "node_modules/cosmiconfig/node_modules/yaml": { version: "1.10.3" }, + "node_modules/@modelcontextprotocol/client": { version: "2.0.0-beta.5" }, + "node_modules/no-version": { resolved: "https://example.test/x.tgz" }, + }, + }; + assert.deepEqual([...topLevelLockVersions(lock)].sort(), [ + ["@modelcontextprotocol/client", "2.0.0-beta.5"], + ["yaml", "2.9.0"], + ["zod", "4.4.3"], + ]); +}); + +test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { + for (const lock of [undefined, null, {}, { packages: {} }]) + assert.equal(topLevelLockVersions(lock).size, 0); +}); + +test("findSkew: reports a package held at two versions", () => { + const installs = [ + { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["zod"]), installs), [ + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + { dir: "clients/cli", version: "4.4.3" }, + ], + }, + ]); +}); + +test("findSkew: agreement and single-install packages are not skew", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "4.4.3"], + ["express", "5.2.1"], + ]), + }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + // `express` lives in one install only, so it cannot skew — a package absent + // from a client is not a finding. + assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []); +}); + +test("findSkew: a candidate in no lockfile is inert", () => { + // `@inspector/core` is a build-time alias, not a package; the scan picks it + // up and it must drop out here rather than error. + const installs = [ + { dir: ".", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []); +}); + +test("findSkew: results are sorted by package name", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "1.0.0"], + ["hono", "1.0.0"], + ]), + }, + { + dir: "clients/web", + versions: new Map([ + ["zod", "2.0.0"], + ["hono", "2.0.0"], + ]), + }, + ]; + assert.deepEqual( + findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name), + ["hono", "zod"], + ); +}); + +test("partitionSkew: the allowlist is by name, not by version pair", () => { + // So an ordinary patch float within a tolerated package does not churn the + // allowlist, while any *unlisted* package that starts skewing still fails. + const skewed = [ + { name: "react", holders: [] }, + { name: "zod", holders: [] }, + ]; + const tolerated = new Map([["react", "shallow interfaces"]]); + const { failures, ignored } = partitionSkew(skewed, tolerated); + assert.deepEqual( + failures.map((s) => s.name), + ["zod"], + ); + assert.deepEqual( + ignored.map((s) => s.name), + ["react"], + ); +}); + +test("partitionSkew: deny by default — nothing tolerated fails everything", () => { + const skewed = [{ name: "zod", holders: [] }]; + assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 8963c8c2..014b850f 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,15 +166,16 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guard: a guard can't detect being unrun itself, but the -// two coverage guards can each assert the other is still wired into `validate`, -// so dropping either is caught here. Only deleting both slips through. +// Vouch for the sibling guards: a guard can't detect being unrun itself, but the +// three can each assert the others are still wired into `validate`, so dropping +// any one is caught here. Only deleting all of them slips through. const rootScripts = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ).scripts; -if (!rootReachesScript(rootScripts, "verify:typecheck-coverage")) { +for (const sibling of ["verify:typecheck-coverage", "verify:dep-lockstep"]) { + if (rootReachesScript(rootScripts, sibling)) continue; console.error( - "verify:format-coverage — the root `validate` no longer runs `verify:typecheck-coverage` (its sibling guard). Restore it.", + `verify:format-coverage — the root \`validate\` no longer runs \`${sibling}\` (its sibling guard). Restore it.`, ); process.exit(1); }