diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md
index 0ba5fc0a9..ca679038f 100644
--- a/.claude/skills/pre-push-gate/SKILL.md
+++ b/.claude/skills/pre-push-gate/SKILL.md
@@ -178,6 +178,25 @@ lowering the gate. See `/testing` for the acceptable reasons.
Also check the file is inside a gated directory — the web coverage `include` is
a whitelist, so a module placed outside it falls out of the gate silently.
+### `verify:coverage-scope`
+
+The last step of `coverage` (#2709). A client's `coverage-summary.json` is
+missing files its own coverage `include`/`exclude` declares, so those files
+were never judged by the per-file gate. The failure names the client and each
+missing file. Two causes:
+
+- **The run's glob matching changed** — typically a Vitest bump. The fix is
+ the client's `include` patterns (an absolute `path.join(dirname, …)` pattern
+ means the same thing under every matcher), never an entry that excuses the
+ files.
+- **A file is in scope but the run legitimately drops it.** State it in
+ `KNOWN_OMISSIONS` in `scripts/verify-coverage-scope.mjs`, with the reason —
+ or, better, add it to the client's coverage `exclude`, which says the same
+ thing where the gate reads it.
+
+"no `coverage-summary.json`" means the client's coverage did not run (or ran
+without the `json-summary` reporter). Run `npm run coverage`.
+
### `smoke:web*`
⚠️ **An orphaned prod web server from a previous run fakes a rejection.** It
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 52a0c8ec5..a05fbcf93 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -217,6 +217,10 @@ jobs:
# This also covers the web integration project: web's `test:coverage`
# runs `--project=unit --project=integration --coverage`, so there is no
# separate integration step anywhere in this workflow.
+ #
+ # `coverage` ends with `verify:coverage-scope` (#2709), which fails if
+ # any client's report measured fewer files than its coverage `include`
+ # declares — the gate otherwise passes on whatever subset it was handed.
run: npm run coverage
# Publish the single `@modelcontextprotocol/inspector` package to npm on a
diff --git a/AGENTS.md b/AGENTS.md
index 8d7c0031f..aad77a190 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -422,7 +422,7 @@ diagnose a failing gate — is the `testing` skill. These are the rules.
- **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0.
- **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling _inside_ the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance.
- **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it.
-- **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself.
+- **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The converse — the run measuring **fewer** files than the `include` declares, as web's did under vitest 5 (418 → 11, still green) — is not silent: `npm run verify:coverage-scope`, the last step of `coverage`, fails naming each missing file (#2709). Fix that by fixing the globs, never by excusing the files. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself.
### Test-gate timeouts are chosen values, stated once
diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts
index c079c57f4..3d22adb3d 100644
--- a/clients/web/vite.config.ts
+++ b/clients/web/vite.config.ts
@@ -252,6 +252,9 @@ export default defineConfig(({ command }) => {
path.join(repoRoot, "core/mcp/remote/types.ts"),
path.join(repoRoot, "core/mcp/import/types.ts"),
"clients/web/server/types.ts",
+ // Type declarations only, so v8 reports no row for it at all — named
+ // here so `verify:coverage-scope` (#2709) is not left expecting one.
+ "src/utils/pendingReauth.ts",
// .d.ts files are declaration-only.
path.join(repoRoot, "**/*.d.ts"),
// inspectorClientEventTarget.ts is types + a single empty-body class
diff --git a/docs/quality-gate.md b/docs/quality-gate.md
index 75b7f1c58..198e71ba0 100644
--- a/docs/quality-gate.md
+++ b/docs/quality-gate.md
@@ -35,7 +35,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 seven durable guards first — `verify:install-fresh` (every install's `node_modules` matches its own lockfile, so a checkout that pulled a dependency bump without re-running `npm install` fails in under a second naming it, instead of minutes later as a behavioral test — #2494), `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), `verify:test-timeouts` (every Vitest project resolves to a budget somebody chose, both web projects configure Testing Library's `asyncUtilTimeout`, and every project loads the runtime no-retry setup), `verify:action-pins` (every action a credentialed workflow job runs is SHA-pinned — #2484) — 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`). **`maxWorkers` is deliberately unset**, here and in every Vitest project ([#2336](https://github.com/modelcontextprotocol/inspector/issues/2336), PR [#2345](https://github.com/modelcontextprotocol/inspector/pull/2345)): the forks-pool projects inherit Vitest's `max(availableParallelism() - 1, 1)` — 7 on the eight-logical-core machine this team works on — and keeping it is a choice with a measurement, recorded beside `TIMEOUTS` in `vitest.shared.mts`: at the leased baseline, arms interleaved over three rounds, `maxWorkers: 4` cost +14% wall on web `unit` and +39% on `coverage:web` for zero fewer failures (0 across all 18 runs). See [Multi-agent testing](#multi-agent-testing-the-gate-lease) for why a cap was the wrong lever for the contention it was proposed against. |
+| `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`). **`maxWorkers` is deliberately unset**, here and in every Vitest project ([#2336](https://github.com/modelcontextprotocol/inspector/issues/2336), PR [#2345](https://github.com/modelcontextprotocol/inspector/pull/2345)): the forks-pool projects inherit Vitest's `max(availableParallelism() - 1, 1)` — 7 on the eight-logical-core machine this team works on — and keeping it is a choice with a measurement, recorded beside `TIMEOUTS` in `vitest.shared.mts`: at the leased baseline, arms interleaved over three rounds, `maxWorkers: 4` cost +14% wall on web `unit` and +39% on `coverage:web` for zero fewer failures (0 across all 18 runs). See [Multi-agent testing](#multi-agent-testing-the-gate-lease) for why a cap was the wrong lever for the contention it was proposed against. Ends with `verify:coverage-scope` (below), which checks that each report measured the file set its client declares. |
| `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. The Playwright budgets the browser smokes wait under are **named once**, in `scripts/lib/browser-timeouts.mjs` (`BROWSER_TIMEOUTS`: `ui` 30s, `roundTrip` 45s, `nested` 15s, `bestEffort` 5s — [#2323](https://github.com/modelcontextprotocol/inspector/issues/2323)); since [#2333](https://github.com/modelcontextprotocol/inspector/issues/2333) (PR [#2349](https://github.com/modelcontextprotocol/inspector/pull/2349)) the shared flow helpers `deep-link-connect.mjs` and `mcp-app-flow.mjs` default to those constants instead of carrying literals of their own, and the two helper budgets that stay put are **stated at the site with the measurement behind them**: `announced-child.mjs`'s `DEFAULTS` (a 30s readiness ceiling on a 250ms poll — boot-to-announce measured 135ms median through it at load average 15, so a passing start pays one poll interval, not the budget) and `pty.mjs`'s `SCRIPT_PROBE_TIMEOUT_MS` (5s for the `script --version` probe, measured at 1.3ms median). That probe's expiry is now a **failure, not a skip**: a timed-out `spawnSync` reports in the same `error` slot as `ENOENT`, and reading both as "no `script(1)` on PATH" had turned `smoke:tui` into an exit-0 skip on exactly the loaded machine the gate is meant to be believable on. |
| `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. |
| `npm run verify:bundle-externals` | Guards the must-not-bundle invariant (#2067): for each tsup-bundled client it reads the **built** `build/` output and fails if any package that must stay external was inlined anyway. Candidates are the union of the client's own `external` array **and the root manifest's `dependencies`** — the latter because #2067 was a *missing* `external` entry, which a self-referential check would have passed. Detection is via esbuild's `// ` module banners, so it covers both shapes — a separate `-HASH.js` chunk (what a dynamically `import()`ed CommonJS package produces) and a statically-imported package folded straight into `index.js`, which emits no chunk at all. A build with no banners fails as such rather than passing clean, so enabling `minify` cannot silently retire the check. `undici` was declared only in the root and `clients/cli` manifests, and tsup auto-externalizes only what the _nearest_ manifest declares, so the web and TUI bundles inlined 1.05MB of it — and CommonJS inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` on first use, from a specifier no user-side install can satisfy. Reads the output rather than the config because those two disagreed for four releases. Part of `npm run local:gate` and of GitHub CI. |
@@ -45,6 +45,7 @@ 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. `gate-lease.test.mjs` ([#2339](https://github.com/modelcontextprotocol/inspector/issues/2339), PR [#2344](https://github.com/modelcontextprotocol/inspector/pull/2344)) covers the gate's lease wrapper the way `announced-child` and `render-smoke` are covered — against a **real** `proper-lockfile` lock and real child processes: two runs serialize; queued waiters take the lease in arrival order even when a later one polls faster, a live ticket ahead holds the line while a dead one is pruned, and an unwritable queue still takes the lease out of turn ([#2473](https://github.com/modelcontextprotocol/inspector/issues/2473)); a stale lock is taken over while one that cannot be removed is waited on; the give-up names the holder; the bypass variable and an unusable lease directory each degrade to an unleased run; and a real `SIGTERM` to the wrapper stops the grandchild, releases the lease and exits 143. Every case uses a throwaway lease directory, so the suite can neither queue behind nor block a developer's actual gate, and an `after` hook removes them ([#2346](https://github.com/modelcontextprotocol/inspector/issues/2346), PR [#2351](https://github.com/modelcontextprotocol/inspector/pull/2351): 14 directories left behind per run → 0). `workflow-gate.test.mjs` also pins `local:gate` to being *exactly* the wrapper over `local:gate:stages` — a stage placed beside the wrapper would run outside the lease — and every assertion about the gate's shape reads the stages script directly. 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:coverage-scope` | The coverage analog of the two guards above ([#2709](https://github.com/modelcontextprotocol/inspector/issues/2709)), for the file set the per-file gate judges. For each client `npm run coverage` gates, it resolves the coverage `include`/`exclude` through Vitest, expands them against the file system with Node's own `fs.globSync` / `path.matchesGlob` — never Vitest's matcher — and **fails** listing every declared file missing from that client's `coverage-summary.json`. Under vitest 5 web's report fell from 418 files to 11 while every remaining file cleared 90, so the gate went green on 3% of its scope ([#2557](https://github.com/modelcontextprotocol/inspector/issues/2557)); this is what turns that red. No count to maintain; deliberate omissions are stated in its `KNOWN_OMISSIONS`; a coverage client it does not list fails deny-by-default. Reads coverage output, so it runs as the last step of `coverage` (locally in `local:gate`, and in CI's `coverage` job), not 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 verify:test-timeouts` | Guards the wall-clock budgets the test gates run under (#2323). The class it encodes against is a budget **nobody chose**: three of the six Vitest projects ran on Vitest's own `testTimeout: 5000` and five on its `hookTimeout`/`teardownTimeout: 10000`, sized for an idle machine rather than the one this team works on — three or four concurrent agent sessions in separate worktrees, each free to run a full `local:gate`, on eight logical cores. A correct, deterministic test cut off by such a budget fails a gate its diff did not break, which is the same "channel nobody trusts" failure [Lint has no warning tier](../AGENTS.md#lint-has-no-warning-tier) describes from the other direction; #2292, #1942 and #1742 were each an instance, found one site at a time. Deliberately **not** cited: #2278 (a missing condition wait around a geometry read) and #2250 (a real race in a test's own timing) were fixed by making the test wait for the right thing, and presenting a race fix as evidence for a larger ceiling would argue against #1596. It asks **Vitest itself** to resolve each of the four configs and reads the number a test actually gets, rather than checking that a key is absent from a config block — which would pass just as happily on a config that had stopped being loaded. A **seventh project** with no row, or a config file it does not discover (it knows all twelve filenames Vitest accepts, not just the two this repo uses), is an error rather than a silent skip. It also checks that every project loads `vitest.setup.shared.mts`. ⚠️ **Everything it reads comes from a resolved project, never from source text** — the two rules no config can report are asserted at runtime instead: `retry` by `vitest.setup.shared.mts` (which reads the value Vitest resolved, so a per-test option, a `describe` option, a project setting and a `--retry` flag are one check) and Testing Library's `asyncUtilTimeout` by `clients/web/src/test/asyncUtilTimeout.test.ts` (which reads what the project's own `waitFor`s use). Both began as source scanning in #2334 and the review found a new valid spelling missed in five consecutive rounds, so the boundary is deliberate: if a rule cannot be answered by asking the tool, assert it at runtime rather than reading the source for it. Observed **red** against the pre-#2323 config before it was trusted. Runs in `validate`; its decision logic has a sibling `verify-test-timeouts.test.mjs` under `test:scripts`.
| `npm run verify:action-pins` | Guards the credentialed-job pinning rule ([#2484](https://github.com/modelcontextprotocol/inspector/issues/2484)). Actions stay on moving major tags by default (#2235), but a tag is mutable, so whoever can move one replaces the code running next to a credential. For every job under `.github/workflows/` that holds `id-token: write` / `packages: write` / `write-all` (its own `permissions:` or the workflow's), is handed any secret other than `GITHUB_TOKEN`, or uploads an artifact such a job downloads and `needs`, it fails on any `uses:` that is not a 40-hex commit SHA followed by an exact `# vX.Y.Z` comment. The comment is what keeps the pin in the monthly `dependency-refresh` sweep, which ranks a SHA pin by it to the patch. Offline, so it cannot check that the SHA matches the comment — resolve both from one tag lookup. |
diff --git a/package.json b/package.json
index 08c666215..5b521ac3e 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"verify:install-fresh": "node scripts/verify-install-fresh.mjs",
"verify:test-timeouts": "node scripts/verify-test-timeouts.mjs",
"verify:action-pins": "node scripts/verify-action-pins.mjs",
+ "verify:coverage-scope": "node scripts/verify-coverage-scope.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}\" --max-warnings 0",
"lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts vitest.setup.shared.mts eslint.config.js --max-warnings 0",
@@ -95,7 +96,7 @@
"validate:tui": "cd clients/tui && npm run validate",
"validate:web": "cd clients/web && npm run validate",
"validate:launcher": "cd clients/launcher && npm run validate",
- "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:mcpdo && npm run coverage:tui && npm run coverage:launcher",
+ "coverage": "npm run coverage:web && npm run coverage:cli && npm run coverage:mcpdo && npm run coverage:tui && npm run coverage:launcher && npm run verify:coverage-scope",
"coverage:cli": "cd clients/cli && npm run test:coverage",
"coverage:mcpdo": "cd clients/mcpdo && npm run test:coverage",
"coverage:tui": "cd clients/tui && npm run test:coverage",
diff --git a/scripts/verify-coverage-scope.mjs b/scripts/verify-coverage-scope.mjs
new file mode 100644
index 000000000..ec119f3d5
--- /dev/null
+++ b/scripts/verify-coverage-scope.mjs
@@ -0,0 +1,353 @@
+/**
+ * Guards the file set each client's coverage run measures (#2709).
+ *
+ * The per-file ≥90 gate only judges the files that reach
+ * `coverage/coverage-summary.json`. Nothing checked that the report holds the
+ * files it should, so when a client's coverage `include` globs stop matching,
+ * the gate shrinks silently and stays green. That happened during the #2557
+ * dependency refresh: under vitest 5, whose fix for "coverage include/exclude
+ * globs too eager" changed how `clients/web`'s mixed relative/absolute
+ * patterns resolve, web's report fell from 418 files to 11 — every remaining
+ * file cleared 90, every test passed, and only a by-hand comparison of the
+ * summary file counts caught it. It is #1301's class (core/ files silently
+ * leaving the gate), reached through a tooling upgrade instead of a path move.
+ *
+ * **The check compares the report against an independent expansion of the
+ * client's own declaration.** For each client, Vitest resolves the config —
+ * so the patterns read here are the ones the run actually used, hard-coded
+ * default exclusions included — but the patterns are then expanded against
+ * the file system with Node's own `fs.globSync` / `path.matchesGlob`, never
+ * with Vitest's matcher. Asking the tool under test which files it meant would
+ * agree with it by construction, which is exactly the blind spot this closes.
+ * There is no file count to maintain: a new file under an included directory
+ * is expected the moment it exists.
+ *
+ * **How a declared pattern is read** — the declaration, not any one Vitest
+ * version's matcher, is the reference:
+ *
+ * • an absolute pattern means itself;
+ * • a relative pattern resolves against the client directory, unless it
+ * starts with the client's own repo-relative path (`clients/web/server/**`
+ * in web's list), which can only mean a repo-root-relative path;
+ * • an exclude pattern removes a file when it matches the file's absolute,
+ * client-relative or repo-relative path. That is deliberately lenient: an
+ * over-broad exclude can only make the guard expect fewer files, never
+ * report a file as missing that the run was right to drop.
+ *
+ * Every client gated by `npm run coverage` is checked, and a client with a
+ * `test:coverage` script that this guard does not list fails loudly rather
+ * than going unchecked — the same deny-by-default as `verify:test-timeouts`.
+ *
+ * It reads coverage output, so it runs at the end of the root `coverage`
+ * script (and with it in `local:gate` and the CI `coverage` job), not in
+ * `validate`.
+ *
+ * ⚠️ Observed to FAIL before it was trusted: with web's summary reduced to
+ * the `clients/web/server/**` rows a vitest 5 run produced, it names every
+ * web component, hook, lib, util and core/ file that dropped out.
+ */
+
+import {
+ existsSync,
+ globSync,
+ readFileSync,
+ readdirSync,
+ statSync,
+} from "node:fs";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptsDir, "..");
+
+/**
+ * Every client whose coverage run `npm run coverage` gates, and the config
+ * file that declares its coverage scope.
+ */
+export const CLIENTS = [
+ { name: "web", config: "vite.config.ts" },
+ { name: "cli", config: "vitest.config.ts" },
+ { name: "mcpdo", config: "vitest.config.ts" },
+ { name: "tui", config: "vitest.config.ts" },
+ { name: "launcher", config: "vitest.config.ts" },
+];
+
+/**
+ * Files a client's declaration matches that its coverage run legitimately
+ * omits, keyed by client, as repo-relative paths. Each entry must say why.
+ * Empty today: on `v2/main` every client's report holds exactly what its
+ * declaration expands to.
+ *
+ * @type {Record>}
+ */
+export const KNOWN_OMISSIONS = {};
+
+/** @param {string} p */
+const slash = (p) => p.split(path.sep).join("/");
+
+/**
+ * Expand one client's coverage `include`/`exclude` against the file system.
+ *
+ * @param {{
+ * include: readonly string[],
+ * exclude: readonly string[],
+ * clientDir: string,
+ * repoRoot: string,
+ * }} scope absolute `clientDir` and `repoRoot`
+ * @returns {string[]} sorted absolute, forward-slashed file paths
+ */
+export function expandCoverageScope({ include, exclude, clientDir, repoRoot }) {
+ const clientPrefix = `${slash(path.relative(repoRoot, clientDir))}/`;
+ const files = new Set();
+ for (const raw of include) {
+ const pattern = slash(raw);
+ let matches;
+ if (path.isAbsolute(raw)) {
+ matches = globSync(pattern);
+ } else {
+ const base = pattern.startsWith(clientPrefix) ? repoRoot : clientDir;
+ matches = globSync(pattern, { cwd: base }).map((f) =>
+ path.resolve(base, f),
+ );
+ }
+ for (const f of matches) files.add(slash(path.resolve(f)));
+ }
+ const excludes = exclude.map(slash);
+ return (
+ [...files]
+ .filter((abs) => {
+ const candidates = [
+ abs,
+ slash(path.relative(clientDir, abs)),
+ slash(path.relative(repoRoot, abs)),
+ ];
+ return !excludes.some((p) =>
+ candidates.some((c) => path.matchesGlob(c, p)),
+ );
+ })
+ // `globSync` returns directories too; a coverage report holds files.
+ .filter((abs) => statSync(abs).isFile())
+ .sort()
+ );
+}
+
+/**
+ * Compare the expected file set with the files a coverage summary reports.
+ *
+ * @param {{
+ * client: string,
+ * expected: readonly string[],
+ * reported: readonly string[],
+ * omissions?: Record,
+ * repoRoot: string,
+ * }} input absolute, forward-slashed paths
+ * @returns {string[]} failure messages; empty when the scope holds
+ */
+export function diffCoverageScope({
+ client,
+ expected,
+ reported,
+ omissions = {},
+ repoRoot,
+}) {
+ const failures = [];
+ if (expected.length === 0) {
+ failures.push(
+ `${client}: its coverage include expands to no files at all — the ` +
+ `declaration no longer names anything on disk`,
+ );
+ return failures;
+ }
+ const have = new Set(reported.map(slash));
+ const rel = (abs) => slash(path.relative(repoRoot, abs));
+ // An omission is honored only with a stated reason; an entry without one
+ // would weaken the gate as silently as the glob drift this guard exists for.
+ const isJustified = (r) =>
+ typeof omissions[r] === "string" && omissions[r].trim() !== "";
+ const missing = expected.filter(
+ (f) => !have.has(f) && !(rel(f) in omissions && isJustified(rel(f))),
+ );
+ if (missing.length > 0) {
+ failures.push(
+ `${client}: ${missing.length} of ${expected.length} file(s) its coverage ` +
+ `include declares are missing from coverage-summary.json (the report ` +
+ `holds ${have.size}) — the run measured a narrower scope than it ` +
+ `declares, so those files are not under the per-file gate:\n` +
+ missing.map((f) => ` ${rel(f)}`).join("\n"),
+ );
+ }
+ const expectedRel = new Set(expected.map(rel));
+ const reportedRel = new Set([...have].map(rel));
+ for (const omitted of Object.keys(omissions)) {
+ if (!isJustified(omitted)) {
+ failures.push(
+ `${client}: KNOWN_OMISSIONS names ${omitted} without a reason — ` +
+ `state why the run may omit it, or remove the entry`,
+ );
+ } else if (!expectedRel.has(omitted)) {
+ failures.push(
+ `${client}: KNOWN_OMISSIONS names ${omitted}, which its declaration ` +
+ `no longer matches — a stale entry hides nothing and should be removed`,
+ );
+ } else if (reportedRel.has(omitted)) {
+ failures.push(
+ `${client}: KNOWN_OMISSIONS names ${omitted}, which the report now ` +
+ `measures — the exemption is stale and should be removed`,
+ );
+ }
+ }
+ return failures;
+}
+
+/**
+ * Clients on disk with a `test:coverage` script that `CLIENTS` does not list,
+ * and listed clients with no such script.
+ *
+ * @param {string[]} discovered client names with a `test:coverage` script
+ * @param {readonly {name: string}[]} [configured]
+ * @returns {string[]}
+ */
+export function checkClientRoster(discovered, configured = CLIENTS) {
+ const known = new Set(configured.map((c) => c.name));
+ const failures = [];
+ for (const name of discovered) {
+ if (!known.has(name)) {
+ failures.push(
+ `clients/${name} has a test:coverage script this guard does not ` +
+ `check — add it to CLIENTS rather than leaving its scope unguarded`,
+ );
+ }
+ }
+ for (const name of known) {
+ if (!discovered.includes(name)) {
+ failures.push(
+ `CLIENTS names ${name}, which has no test:coverage script — a stale ` +
+ `row silently stops checking anything`,
+ );
+ }
+ }
+ return failures;
+}
+
+/**
+ * @param {string} root
+ * @returns {string[]}
+ */
+function discoverCoverageClients(root) {
+ const clientsDir = path.join(root, "clients");
+ return readdirSync(clientsDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ .filter((name) => {
+ const manifest = path.join(clientsDir, name, "package.json");
+ if (!existsSync(manifest)) return false;
+ const scripts = JSON.parse(readFileSync(manifest, "utf8")).scripts ?? {};
+ return typeof scripts["test:coverage"] === "string";
+ })
+ .sort();
+}
+
+/**
+ * Resolve a client's coverage options through Vitest's own config resolver,
+ * so the patterns checked are the ones the run used.
+ *
+ * @param {string} clientDir
+ * @param {string} configFile
+ * @returns {Promise<{include: string[], exclude: string[]}>}
+ */
+async function resolveCoverageOptions(clientDir, configFile) {
+ const { createVitest } = await import("vitest/node");
+ const vitest = await createVitest("test", {
+ watch: false,
+ run: true,
+ root: clientDir,
+ config: path.join(clientDir, configFile),
+ });
+ try {
+ const { include, exclude } = vitest.config.coverage;
+ return { include: include ?? [], exclude: exclude ?? [] };
+ } finally {
+ await vitest.close();
+ }
+}
+
+/**
+ * @param {string} summaryPath
+ * @returns {string[] | null} reported absolute paths, or null when absent
+ */
+function readReportedFiles(summaryPath) {
+ if (!existsSync(summaryPath)) return null;
+ const summary = JSON.parse(readFileSync(summaryPath, "utf8"));
+ return Object.keys(summary).filter((k) => k !== "total");
+}
+
+/**
+ * @param {{
+ * root?: string,
+ * clients?: readonly {name: string, config: string}[],
+ * omissions?: Record>,
+ * resolveOptions?: typeof resolveCoverageOptions,
+ * log?: (line: string) => void,
+ * }} [deps]
+ * @returns {Promise} failures
+ */
+export async function run({
+ root = repoRoot,
+ clients = CLIENTS,
+ omissions = KNOWN_OMISSIONS,
+ resolveOptions = resolveCoverageOptions,
+ log = (line) => console.log(line),
+} = {}) {
+ const failures = checkClientRoster(discoverCoverageClients(root), clients);
+ for (const { name, config } of clients) {
+ const clientDir = path.join(root, "clients", name);
+ const summaryPath = path.join(
+ clientDir,
+ "coverage",
+ "coverage-summary.json",
+ );
+ const reported = readReportedFiles(summaryPath);
+ if (reported === null) {
+ failures.push(
+ `${name}: no ${slash(path.relative(root, summaryPath))} — run its ` +
+ `coverage first (\`npm run coverage\`); this guard reads its output`,
+ );
+ continue;
+ }
+ const { include, exclude } = await resolveOptions(clientDir, config);
+ const expected = expandCoverageScope({
+ include,
+ exclude,
+ clientDir,
+ repoRoot: root,
+ });
+ const clientFailures = diffCoverageScope({
+ client: name,
+ expected,
+ reported,
+ omissions: omissions[name],
+ repoRoot: root,
+ });
+ failures.push(...clientFailures);
+ if (clientFailures.length === 0) {
+ log(` ${name}: ${expected.length} declared file(s), all measured`);
+ }
+ }
+ return failures;
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ console.log(
+ "verify:coverage-scope — coverage reports hold their declared scope",
+ );
+ const failures = await run();
+ if (failures.length > 0) {
+ console.error(`\n✗ ${failures.length} coverage-scope failure(s):\n`);
+ for (const f of failures) console.error(` - ${f}\n`);
+ process.exit(1);
+ }
+ console.log("✓ every client's coverage report holds its declared file set");
+}
diff --git a/scripts/verify-coverage-scope.test.mjs b/scripts/verify-coverage-scope.test.mjs
new file mode 100644
index 000000000..4f4f97e52
--- /dev/null
+++ b/scripts/verify-coverage-scope.test.mjs
@@ -0,0 +1,350 @@
+// Unit tests for verify-coverage-scope.mjs (#2709): how a declared coverage
+// scope is expanded, how a report is compared against it, and the client
+// roster's deny-by-default. `run()` is driven over a throwaway repo with an
+// injected config resolver, so the vitest-5 shape that motivated the guard —
+// a report collapsed to `clients/web/server/**` — is reproduced without
+// installing vitest 5. Run via `npm run test:scripts`.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ CLIENTS,
+ checkClientRoster,
+ diffCoverageScope,
+ expandCoverageScope,
+ run,
+} from "./verify-coverage-scope.mjs";
+
+const slash = (p) => p.split(path.sep).join("/");
+
+/**
+ * A throwaway repo holding the given files (repo-relative), each with a stub
+ * body. realpath'd because macOS `tmpdir()` is a symlink and the guard
+ * compares resolved paths.
+ *
+ * @param {string[]} files
+ */
+function makeRepo(files) {
+ const root = realpathSync(mkdtempSync(path.join(tmpdir(), "cov-scope-")));
+ for (const f of files) {
+ const abs = path.join(root, f);
+ mkdirSync(path.dirname(abs), { recursive: true });
+ writeFileSync(abs, "export {};\n");
+ }
+ return root;
+}
+
+const abs = (root, ...rel) => rel.map((r) => slash(path.join(root, r)));
+
+test("a relative include resolves against the client directory", (t) => {
+ const root = makeRepo([
+ "clients/web/src/components/A.tsx",
+ "clients/web/src/components/b/B.ts",
+ "src/components/NotThisOne.tsx",
+ ]);
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const expected = expandCoverageScope({
+ include: ["src/components/**/*.{ts,tsx}"],
+ exclude: [],
+ clientDir: path.join(root, "clients/web"),
+ repoRoot: root,
+ });
+ assert.deepEqual(
+ expected,
+ abs(
+ root,
+ "clients/web/src/components/A.tsx",
+ "clients/web/src/components/b/B.ts",
+ ),
+ );
+});
+
+test("an include starting with the client's own repo path resolves against the repo root", (t) => {
+ const root = makeRepo(["clients/web/server/s.ts"]);
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const expected = expandCoverageScope({
+ include: ["clients/web/server/**/*.{ts,tsx}"],
+ exclude: [],
+ clientDir: path.join(root, "clients/web"),
+ repoRoot: root,
+ });
+ assert.deepEqual(expected, abs(root, "clients/web/server/s.ts"));
+});
+
+test("an absolute include means itself", (t) => {
+ const root = makeRepo(["core/json/a.ts", "core/json/deep/b.ts"]);
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const expected = expandCoverageScope({
+ include: [path.join(root, "core/json/**/*.{ts,tsx}")],
+ exclude: [],
+ clientDir: path.join(root, "clients/web"),
+ repoRoot: root,
+ });
+ assert.deepEqual(
+ expected,
+ abs(root, "core/json/a.ts", "core/json/deep/b.ts"),
+ );
+});
+
+test("an exclude matches absolute, client-relative or repo-relative paths", (t) => {
+ const root = makeRepo([
+ "clients/web/src/lib/keep.ts",
+ "clients/web/src/lib/keep.test.ts",
+ "clients/web/src/lib/index.ts",
+ "clients/web/server/plugin.ts",
+ "clients/web/server/kept.ts",
+ "core/mcp/types.ts",
+ "core/mcp/client.ts",
+ ]);
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const expected = expandCoverageScope({
+ include: [
+ "src/lib/**/*.ts",
+ "clients/web/server/**/*.ts",
+ path.join(root, "core/mcp/**/*.ts"),
+ ],
+ exclude: [
+ "**/*.test.ts", // glob over any form
+ "src/lib/index.ts", // client-relative
+ "clients/web/server/plugin.ts", // repo-relative
+ path.join(root, "core/mcp/types.ts"), // absolute
+ ],
+ clientDir: path.join(root, "clients/web"),
+ repoRoot: root,
+ });
+ assert.deepEqual(
+ expected,
+ abs(
+ root,
+ "clients/web/server/kept.ts",
+ "clients/web/src/lib/keep.ts",
+ "core/mcp/client.ts",
+ ),
+ );
+});
+
+test("directories a glob matches are not expected files", (t) => {
+ const root = makeRepo(["clients/tui/src/dir.ts/inner.ts"]);
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const expected = expandCoverageScope({
+ include: ["src/**/*.ts"],
+ exclude: [],
+ clientDir: path.join(root, "clients/tui"),
+ repoRoot: root,
+ });
+ assert.deepEqual(expected, abs(root, "clients/tui/src/dir.ts/inner.ts"));
+});
+
+test("a report holding the declared set passes", () => {
+ const expected = ["/r/clients/cli/src/a.ts", "/r/clients/cli/src/b.ts"];
+ assert.deepEqual(
+ diffCoverageScope({
+ client: "cli",
+ expected,
+ reported: [...expected].reverse(),
+ repoRoot: "/r",
+ }),
+ [],
+ );
+});
+
+test("a narrower report fails, naming the client and each missing file", () => {
+ const failures = diffCoverageScope({
+ client: "web",
+ expected: [
+ "/r/clients/web/server/s.ts",
+ "/r/clients/web/src/components/A.tsx",
+ "/r/core/json/a.ts",
+ ],
+ reported: ["/r/clients/web/server/s.ts"],
+ repoRoot: "/r",
+ });
+ assert.equal(failures.length, 1);
+ assert.match(failures[0], /^web: 2 of 3 file\(s\)/);
+ assert.match(failures[0], /report holds 1/);
+ assert.match(failures[0], /\n {4}clients\/web\/src\/components\/A\.tsx\n/);
+ assert.match(failures[0], /\n {4}core\/json\/a\.ts$/);
+});
+
+test("an include that expands to nothing fails rather than passing vacuously", () => {
+ const failures = diffCoverageScope({
+ client: "tui",
+ expected: [],
+ reported: [],
+ repoRoot: "/r",
+ });
+ assert.equal(failures.length, 1);
+ assert.match(failures[0], /expands to no files at all/);
+});
+
+test("a stated omission is not reported missing, and a stale one is flagged", () => {
+ const ok = diffCoverageScope({
+ client: "web",
+ expected: ["/r/clients/web/src/a.ts", "/r/clients/web/src/b.ts"],
+ reported: ["/r/clients/web/src/a.ts"],
+ omissions: { "clients/web/src/b.ts": "types only" },
+ repoRoot: "/r",
+ });
+ assert.deepEqual(ok, []);
+ const stale = diffCoverageScope({
+ client: "web",
+ expected: ["/r/clients/web/src/a.ts"],
+ reported: ["/r/clients/web/src/a.ts"],
+ omissions: { "clients/web/src/gone.ts": "types only" },
+ repoRoot: "/r",
+ });
+ assert.equal(stale.length, 1);
+ assert.match(stale[0], /KNOWN_OMISSIONS names clients\/web\/src\/gone\.ts/);
+});
+
+test("an omission without a reason is not honored and is itself a failure", () => {
+ for (const reason of ["", " ", undefined]) {
+ const failures = diffCoverageScope({
+ client: "web",
+ expected: ["/r/clients/web/src/a.ts", "/r/clients/web/src/b.ts"],
+ reported: ["/r/clients/web/src/a.ts"],
+ omissions: { "clients/web/src/b.ts": reason },
+ repoRoot: "/r",
+ });
+ assert.equal(failures.length, 2, `reason ${JSON.stringify(reason)}`);
+ assert.match(failures[0], /^web: 1 of 2 file\(s\)/);
+ assert.match(failures[0], /clients\/web\/src\/b\.ts/);
+ assert.match(
+ failures[1],
+ /names clients\/web\/src\/b\.ts without a reason/,
+ );
+ }
+});
+
+test("an omission for a file the report now measures is flagged as stale", () => {
+ const failures = diffCoverageScope({
+ client: "web",
+ expected: ["/r/clients/web/src/a.ts"],
+ reported: ["/r/clients/web/src/a.ts"],
+ omissions: { "clients/web/src/a.ts": "types only" },
+ repoRoot: "/r",
+ });
+ assert.equal(failures.length, 1);
+ assert.match(failures[0], /which the report now measures/);
+});
+
+test("the roster fails on an unlisted coverage client and on a stale row", () => {
+ const listed = CLIENTS.map((c) => c.name);
+ assert.deepEqual(checkClientRoster([...listed].sort()), []);
+ const unlisted = checkClientRoster([...listed, "newclient"]);
+ assert.equal(unlisted.length, 1);
+ assert.match(unlisted[0], /clients\/newclient has a test:coverage script/);
+ const stale = checkClientRoster(listed.filter((n) => n !== "tui"));
+ assert.equal(stale.length, 1);
+ assert.match(stale[0], /CLIENTS names tui/);
+});
+
+test("the shipped roster is the five clients `npm run coverage` gates", () => {
+ assert.deepEqual(CLIENTS.map((c) => c.name).sort(), [
+ "cli",
+ "launcher",
+ "mcpdo",
+ "tui",
+ "web",
+ ]);
+});
+
+/**
+ * A fixture repo with one coverage client, `web`, whose declaration matches
+ * three files — two outside `clients/web/server/**`.
+ */
+function makeWebFixture() {
+ const root = makeRepo([
+ "clients/web/server/s.ts",
+ "clients/web/src/components/A.tsx",
+ "core/json/a.ts",
+ ]);
+ writeFileSync(
+ path.join(root, "clients/web/package.json"),
+ JSON.stringify({ scripts: { "test:coverage": "vitest run --coverage" } }),
+ );
+ const resolveOptions = async () => ({
+ include: [
+ "src/components/**/*.{ts,tsx}",
+ "clients/web/server/**/*.{ts,tsx}",
+ path.join(root, "core/json/**/*.{ts,tsx}"),
+ ],
+ exclude: ["**/node_modules/**"],
+ });
+ /** @param {string[]} rel */
+ const writeSummary = (rel) => {
+ const summary = { total: {} };
+ for (const r of rel) summary[path.join(root, r)] = {};
+ mkdirSync(path.join(root, "clients/web/coverage"), { recursive: true });
+ writeFileSync(
+ path.join(root, "clients/web/coverage/coverage-summary.json"),
+ JSON.stringify(summary),
+ );
+ };
+ return { root, resolveOptions, writeSummary };
+}
+
+const webOnly = [{ name: "web", config: "vite.config.ts" }];
+
+test("run(): a complete report passes", async (t) => {
+ const { root, resolveOptions, writeSummary } = makeWebFixture();
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ writeSummary([
+ "clients/web/server/s.ts",
+ "clients/web/src/components/A.tsx",
+ "core/json/a.ts",
+ ]);
+ const lines = [];
+ const failures = await run({
+ root,
+ clients: webOnly,
+ omissions: {},
+ resolveOptions,
+ log: (l) => lines.push(l),
+ });
+ assert.deepEqual(failures, []);
+ assert.deepEqual(lines, [" web: 3 declared file(s), all measured"]);
+});
+
+test("run(): the vitest-5 collapse to clients/web/server/** fails (#2557)", async (t) => {
+ const { root, resolveOptions, writeSummary } = makeWebFixture();
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ writeSummary(["clients/web/server/s.ts"]);
+ const failures = await run({
+ root,
+ clients: webOnly,
+ omissions: {},
+ resolveOptions,
+ log: () => {},
+ });
+ assert.equal(failures.length, 1);
+ assert.match(failures[0], /^web: 2 of 3 file\(s\)/);
+ assert.match(failures[0], /clients\/web\/src\/components\/A\.tsx/);
+ assert.match(failures[0], /core\/json\/a\.ts/);
+});
+
+test("run(): a missing summary fails rather than being skipped", async (t) => {
+ const { root, resolveOptions } = makeWebFixture();
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const failures = await run({
+ root,
+ clients: webOnly,
+ omissions: {},
+ resolveOptions,
+ log: () => {},
+ });
+ assert.equal(failures.length, 1);
+ assert.match(
+ failures[0],
+ /^web: no clients\/web\/coverage\/coverage-summary\.json/,
+ );
+});