Merge pull request #2343 from modelcontextprotocol/v2/chore/2341-gate-single-test-pass

chore: run each client's test suite once in local:gate, not twice
This commit is contained in:
Cliff Hall 2026-09-12 21:08:55 -04:00 • committed by GitHub
commit aa56551be2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 211 additions and 41 deletions

View file

@ -30,10 +30,19 @@ review, which is the argument for not making a fourth. `npm run local:gate`
prints each stage as it starts, so the running command is the other reliable prints each stage as it starts, so the running command is the other reliable
answer. answer.
It is a **strict superset** of GitHub CI (which additionally runs `npm install`, It runs **every check** GitHub CI runs (which additionally runs `npm install`,
and runs `coverage` as a parallel job). So the direction that matters holds: and runs `coverage` as a parallel job), plus two local-only steps. So the
**passing `local:gate` locally means CI's gates will pass.** The reverse does direction that matters holds: **passing `local:gate` locally means every check
not. CI applies has already passed on your machine** — the strongest predictor of a
green CI there is here, though not a proof (a different OS, and the bare test
pass noted below). The reverse does not hold at all.
One difference in *invocations*, not checks: CI runs each client's unit suite
twice — bare inside `validate`, instrumented inside `coverage` — on two
parallel runners, while the gate runs it **once**, instrumented (#2341). The
gate's first stage is `local:validate`, which is `validate` minus each client's
`test` leg; `npm run validate` itself is unchanged. The reasoning is in
[`AGENTS.md`](../../../AGENTS.md#mandatory-pre-push-gate).
⚠️ **There is no `npm run ci`.** The gate was renamed to `local:gate` (#2146) ⚠️ **There is no `npm run ci`.** The gate was renamed to `local:gate` (#2146)
precisely because `npm ci` is a built-in that clean-installs from the lockfile precisely because `npm ci` is a built-in that clean-installs from the lockfile

View file

@ -49,7 +49,9 @@ jobs:
# (#2159), which consumes nothing this job produces — every client's # (#2159), which consumes nothing this job produces — every client's
# `test:coverage` builds whatever it needs itself. Unit tests run in # `test:coverage` builds whatever it needs itself. Unit tests run in
# both jobs (fast here, instrumented there); now that the two run in # both jobs (fast here, instrumented there); now that the two run in
# parallel that duplication costs no wall clock at all. # parallel that duplication costs no wall clock at all. (The local gate,
# which runs serially, calls `local:validate` instead and runs them once
# — #2341. Keep `validate` here: this job IS the fast inner loop.)
# A future optimization could split `coverage` into per-client parallel # A future optimization could split `coverage` into per-client parallel
# jobs, but that's a larger restructure and deliberately out of scope. # jobs, but that's a larger restructure and deliberately out of scope.
run: npm run validate run: npm run validate
@ -109,9 +111,10 @@ jobs:
- name: Run cross-client smokes - name: Run cross-client smokes
# NOTE: this workflow runs GitHub CI's tier only. The LOCAL pre-push # NOTE: this workflow runs GitHub CI's tier only. The LOCAL pre-push
# gate is `npm run local:gate`, and it is a strict superset — do not add # gate is `npm run local:gate`, and it runs every check here plus the
# it, a `local:*` script, `smoke:web:firefox`/`smoke:web:webkit`, # local-only ones — do not add it, a `local:*` script,
# `smoke:web:engine`, or a non-Chromium `SMOKE_BROWSER` to any workflow. # `smoke:web:firefox`/`smoke:web:webkit`, `smoke:web:engine`, or a
# non-Chromium `SMOKE_BROWSER` to any workflow.
# `npm run test:scripts` fails if you do (scripts/lib/workflow-gate.mjs, # `npm run test:scripts` fails if you do (scripts/lib/workflow-gate.mjs,
# #2146). `npm run smoke` and `smoke:web:chromium` belong here and are # #2146). `npm run smoke` and `smoke:web:chromium` belong here and are
# not affected. The canonical CI-vs-local table is in the root README. # not affected. The canonical CI-vs-local table is in the root README.

View file

@ -217,8 +217,8 @@ Skills are conditional — a skill's body loads only when it is invoked — so a
skill that stops being reachable loses behavior **silently**. Four rules keep skill that stops being reachable loses behavior **silently**. Four rules keep
that from happening: that from happening:
1. **`npm run verify:skills` must pass.** It runs inside `validate` (and so in 1. **`npm run verify:skills` must pass.** It runs inside `validate:guards`,
`local:gate` and in CI). It parses each `SKILL.md`'s frontmatter the way Claude which `validate` (CI) and `local:validate` (the gate) both run first. It parses each `SKILL.md`'s frontmatter the way Claude
Code does and fails on anything that would strip the metadata — most importantly Code does and fails on anything that would strip the metadata — most importantly
**malformed YAML**, which loads the body with an _empty_ description, so **malformed YAML**, which loads the body with an _empty_ description, so
`/skill-name` still works and a manual spot check passes while the skill can `/skill-name` still works and a manual spot check passes while the skill can
@ -473,7 +473,8 @@ free to run a full `local:gate`). Raising a budget nobody chose hides no race.
## Mandatory pre-push gate ## Mandatory pre-push gate
- **ALWAYS run `npm run format` before committing.** The **root** `format` auto-fixes `core/`, the root `scripts/` tooling, the root shared surface, and every client's scope in one shot. `validate` runs the non-fixing `format:check` and will fail in CI on any unformatted file, so run the auto-fixer first rather than letting `format:check` catch it. - **ALWAYS run `npm run format` before committing.** The **root** `format` auto-fixes `core/`, the root `scripts/` tooling, the root shared surface, and every client's scope in one shot. `validate` runs the non-fixing `format:check` and will fail in CI on any unformatted file, so run the auto-fixer first rather than letting `format:check` catch it.
- **`npm run local:gate` is the mandatory pre-push command.** It is a **strict superset** of `.github/workflows/main.yml`, so passing it locally means CI's gates will pass. Expect several minutes. - **`npm run local:gate` is the mandatory pre-push command.** It runs **every check** `.github/workflows/main.yml` runs, plus two local-only steps, so a green run here is the strongest predictor of a green CI this repo has: every check CI applies has already passed on your machine. It is not a proof — CI runs on a different OS and, since #2341, runs each client's suite bare where the gate runs it only instrumented — so the one residual is a test that passes *only* when slowed down, which is a race (#1596) to fix, never headroom to keep. Expect several minutes.
- **The gate runs each client's test suite once, instrumented; CI runs it twice (#2341).** CI's `build` job runs the bare `test` inside `validate` and its parallel `coverage` job runs `test:coverage`, which costs CI no wall clock. Serially in one process the bare pass was ~80s of a ~370s gate, re-running exactly the files the coverage pass runs a few minutes later — so the gate calls **`local:validate`**, which is `validate` minus each client's `test` leg (every client's `validate` is `check && test`, and `local:validate` runs the `check` half). "Every check" is therefore a claim about checks, not invocations, and it holds because `@vitest/coverage-v8` collects coverage from V8's own profiler (`Profiler.takePreciseCoverage`) and rewrites no source: a test file sees identical code either way, and the instrumented run is only *slower*, which makes it the stricter of the two for the failure class this repo actually sees (a correct test cut off under load). A test that passed only *because* it ran slower would be a race — #1596's class, a defect wherever it surfaces — and CI's bare pass still runs it. **`npm run validate` is unchanged**, because CI runs it directly and it is the inner-loop check; `verify:*` guards that ask "is this reachable from `validate`" are unaffected. `local:validate` lives in the `local:` namespace so the workflow guard keeps it out of CI by construction.
- **`npm run validate` is the fast inner-loop check and is NOT an acceptable substitute.** It runs `test`, not `test:coverage`, so it does **zero** coverage gating, no smokes, and no Storybook tests. Skipping the gate is how a push passes every fast local check and still fails CI. - **`npm run validate` is the fast inner-loop check and is NOT an acceptable substitute.** It runs `test`, not `test:coverage`, so it does **zero** coverage gating, no smokes, and no Storybook tests. Skipping the gate is how a push passes every fast local check and still fails CI.
- There is deliberately **no `npm run ci`** — that name collided with the `npm ci` built-in, which clean-installs from the lockfile and does not run this script. - There is deliberately **no `npm run ci`** — that name collided with the `npm ci` built-in, which clean-installs from the lockfile and does not run this script.
- What each stage covers, and why two of them are local-only, is [`docs/quality-gate.md`](./docs/quality-gate.md); how to diagnose a failing stage is the `pre-push-gate` skill. - What each stage covers, and why two of them are local-only, is [`docs/quality-gate.md`](./docs/quality-gate.md); how to diagnose a failing stage is the `pre-push-gate` skill.
@ -508,7 +509,7 @@ The two coverage guards do **not** catch this, and adding a third is not the fix
**Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085).** All six scopes carry the flag — each of `clients/{web,cli,tui,launcher}`'s `eslint .`, plus the root's `lint:core` and `lint:shared`. **Every `lint` script runs with `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085).** All six scopes carry the flag — each of `clients/{web,cli,tui,launcher}`'s `eslint .`, plus the root's `lint:core` and `lint:shared`.
This exists because the gate's promise — that passing `npm run local:gate` locally means CI's gates pass — was kept while a real bug walked through it. `react-hooks/exhaustive-deps` ships at `warn` in the recommended set, and two `useCallback`s in `App.tsx` omitted a non-stable `refresh` from their dependency arrays; ESLint printed the right message on both lines on every run, nothing consumed it, and the stale closure was caught only by a review round on #2076. It is the same argument [Build output is never a gate target](#build-output-is-never-a-gate-target) makes from the other direction: a channel nobody fails on is one people learn to skim. This exists because the gate's promise — that a green `npm run local:gate` means every check CI applies has already passed — was kept while a real bug walked through it. `react-hooks/exhaustive-deps` ships at `warn` in the recommended set, and two `useCallback`s in `App.tsx` omitted a non-stable `refresh` from their dependency arrays; ESLint printed the right message on both lines on every run, nothing consumed it, and the stale closure was caught only by a review round on #2076. It is the same argument [Build output is never a gate target](#build-output-is-never-a-gate-target) makes from the other direction: a channel nobody fails on is one people learn to skim.
Two consequences worth stating: Two consequences worth stating:

View file

@ -88,7 +88,7 @@ Each client self-validates from its own folder; the root scripts chain them. The
```bash ```bash
npm run validate # fast inner loop: format:check + lint + typecheck + build + unit tests npm run validate # fast inner loop: format:check + lint + typecheck + build + unit tests
npm run coverage # the per-file ≥90% gate (lines/statements/functions/branches) npm run coverage # the per-file ≥90% gate (lines/statements/functions/branches)
npm run local:gate # MANDATORY before pushing — a strict superset of GitHub CI npm run local:gate # MANDATORY before pushing — every GitHub CI check, plus two local-only ones
``` ```
`npm run local:gate` chains every check below, plus the smokes and the Storybook tests. [Testing and the quality gate](./docs/quality-gate.md) owns the stage list and says what each one covers and why two are local-only; [`AGENTS.md`](./AGENTS.md) holds the testing rules themselves. `npm run local:gate` chains every check below, plus the smokes and the Storybook tests. [Testing and the quality gate](./docs/quality-gate.md) owns the stage list and says what each one covers and why two are local-only; [`AGENTS.md`](./AGENTS.md) holds the testing rules themselves.

View file

@ -472,16 +472,19 @@ While the Web Client provides a rich visual interface, the CLI is designed for:
Like the other clients, the CLI self-validates from its own folder: Like the other clients, the CLI self-validates from its own folder:
```bash ```bash
npm run validate # format:check && lint && typecheck && test (fast; no coverage gate) npm run check # format:check && lint && typecheck (no tests)
npm run validate # check && test (fast; no coverage gate)
npm test # build test-servers + binary, then run all tests npm test # build test-servers + binary, then run all tests
npm run test:coverage # build + tests under the per-file ≥90 coverage gate npm run test:coverage # build + tests under the per-file ≥90 coverage gate
``` ```
The CLI's `test` / `test:coverage` **build the binary first** (out-of-process The CLI's `test` / `test:coverage` **build the binary first** (out-of-process
`e2e.test.ts` spawns it). `validate` is `format:check && lint && typecheck && test` `e2e.test.ts` spawns it). `check` is `format:check && lint && typecheck` and
with no separate `build` step (`pretest` builds). Repo-root `validate:cli` `validate` is `check && test`, with no separate `build` step (`pretest` builds).
delegates here; the coverage gate is `npm run coverage` / `coverage:cli` (also in Repo-root `validate:cli` delegates to `validate`, and the root `local:validate`
`npm run local:gate`), matching AGENTS.md. — the first stage of `npm run local:gate` — runs `check` instead, so the gate
runs the suite once, under `coverage` / `coverage:cli` (#2341), matching
AGENTS.md.
Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured
under coverage, with a thin out-of-process spawn layer for the real binary. See under coverage, with a thin out-of-process spawn layer for the real binary. See

View file

@ -17,7 +17,8 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run test", "check": "npm run format:check && npm run lint && npm run typecheck",
"validate": "npm run check && npm run test",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage", "test:coverage": "npm run test-servers:build && npm run build && vitest run --coverage",

View file

@ -110,7 +110,8 @@ stale fixture reports a product failure rather than a staleness one.
Like the web client, the launcher self-validates from its own folder: Like the web client, the launcher self-validates from its own folder:
```bash ```bash
npm run validate # format:check && lint && build && test:coverage npm run check # format:check && lint && typecheck && build (no tests)
npm run validate # check && test
``` ```
This has **no** dependency on the other clients being built — it only checks the This has **no** dependency on the other clients being built — it only checks the
@ -118,7 +119,9 @@ launcher's own source. `eslint.config.js` is a Node-only flat config (the web
client's React/Storybook plugins stripped out), and the per-file coverage gate client's React/Storybook plugins stripped out), and the per-file coverage gate
covers `parse-launcher-argv.ts` (the pure arg-parsing logic); `src/index.ts` is covers `parse-launcher-argv.ts` (the pure arg-parsing logic); `src/index.ts` is
excluded as binary bootstrap and is instead exercised by the smokes above. The excluded as binary bootstrap and is instead exercised by the smokes above. The
repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm run validate`). repo-root `validate:launcher` simply delegates here (`cd clients/launcher && npm run validate`),
and the root `local:validate` — the first stage of `npm run local:gate` — runs
`check` instead, so the gate runs the suite once, under `coverage:launcher` (#2341).
## Publishing ## Publishing

View file

@ -20,6 +20,7 @@
"format:check": "prettier --check src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"", "format:check": "prettier --check src __tests__ scripts \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" "check": "npm run format:check && npm run lint && npm run typecheck && npm run build",
"validate": "npm run check && npm run test"
} }
} }

View file

@ -93,12 +93,15 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se
Like the other clients, the TUI self-validates from its own folder: Like the other clients, the TUI self-validates from its own folder:
```bash ```bash
npm run validate # format:check && lint && build && test:coverage npm run check # format:check && lint && typecheck && build (no tests)
npm run validate # check && test
npm test # run all tests npm test # run all tests
npm run test:coverage # run tests under the per-file coverage gate npm run test:coverage # run tests under the per-file coverage gate
``` ```
The repo-root `validate:tui` just delegates here. `eslint.config.js` registers The repo-root `validate:tui` just delegates here; the root `local:validate`
(the first stage of `npm run local:gate`) runs `check` instead, so the gate
runs the suite once, under `coverage:tui` (#2341). `eslint.config.js` registers
`react-hooks` for the classic rules only (rules-of-hooks + exhaustive-deps); the `react-hooks` for the classic rules only (rules-of-hooks + exhaustive-deps); the
stricter react-hooks@7 rules are not enforced on the interim component surface stricter react-hooks@7 rules are not enforced on the interim component surface
(#1501). (#1501).

View file

@ -19,7 +19,8 @@
"dev": "vite-node --config vitest.config.ts dev.ts", "dev": "vite-node --config vitest.config.ts dev.ts",
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
"validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test", "check": "npm run format:check && npm run lint && npm run typecheck && npm run build",
"validate": "npm run check && npm run test",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",

View file

@ -15,7 +15,8 @@
"build:client": "vite build", "build:client": "vite build",
"build:runner": "tsup --config tsup.runner.config.ts", "build:runner": "tsup --config tsup.runner.config.ts",
"build:storybook": "storybook build", "build:storybook": "storybook build",
"validate": "npm run format:check && npm run lint && npm run build && npm run test", "check": "npm run format:check && npm run lint && npm run build",
"validate": "npm run check && npm run test",
"pretest": "npm run test-servers:build", "pretest": "npm run test-servers:build",
"test": "vitest run --project=unit", "test": "vitest run --project=unit",
"test:watch": "vitest --project=unit", "test:watch": "vitest --project=unit",

View file

@ -12,16 +12,18 @@ Each client self-validates from its own folder; the root scripts chain them. The
| Tier | How it runs | What it covers | | Tier | How it runs | What it covers |
| --- | --- | --- | | --- | --- | --- |
| **GitHub CI** (`.github/workflows/main.yml`) | Automatically, on every push | `npm install`, then `validate`, `verify:skills:cli`, `verify:build-gate`, `verify:bundle-externals`, `smoke` (which includes `smoke:web:chromium`), `test:storybook` — plus `coverage` in a parallel job ([#2159](https://github.com/modelcontextprotocol/inspector/issues/2159)) | | **GitHub CI** (`.github/workflows/main.yml`) | Automatically, on every push | `npm install`, then `validate`, `verify:skills:cli`, `verify:build-gate`, `verify:bundle-externals`, `smoke` (which includes `smoke:web:chromium`), `test:storybook` — plus `coverage` in a parallel job ([#2159](https://github.com/modelcontextprotocol/inspector/issues/2159)) |
| **The local gate** (`npm run local:gate`) | By hand, before you push | Every check above (the install is yours to run), **plus** the Firefox engine pass (`smoke:web:firefox`), and `smoke:tui` for real rather than self-skipped | | **The local gate** (`npm run local:gate`) | By hand, before you push | Every check above (the install is yours to run; `local:validate` stands in for `validate`, see below), **plus** the Firefox engine pass (`smoke:web:firefox`), and `smoke:tui` for real rather than self-skipped |
The local gate is a **strict superset**, not a mirror. Two of its steps have no GitHub CI counterpart, each for its own reason: The local gate runs **every check** CI runs, and is not a mirror. Two of its steps have no GitHub CI counterpart, each for its own reason:
| Local-only step | Why it is local-only | | Local-only step | Why it is local-only |
| --- | --- | | --- | --- |
| `smoke:web:firefox` | Trialled as a CI job and removed ([#2086](https://github.com/modelcontextprotocol/inspector/issues/2086)): across a dozen runs it never once disagreed with Chromium, and `playwright install --with-deps` carries a real flake surface. Kept in front of a human about to push instead. `smoke:web:webkit` is worse still — it fails two of the three smokes for reasons nobody has identified — so it is in neither tier. See [Supported browsers](#supported-browsers). | | `smoke:web:firefox` | Trialled as a CI job and removed ([#2086](https://github.com/modelcontextprotocol/inspector/issues/2086)): across a dozen runs it never once disagreed with Chromium, and `playwright install --with-deps` carries a real flake surface. Kept in front of a human about to push instead. `smoke:web:webkit` is worse still — it fails two of the three smokes for reasons nobody has identified — so it is in neither tier. See [Supported browsers](#supported-browsers). |
| `smoke:tui` | The Ink TUI needs a real TTY. It _is_ invoked in CI via `npm run smoke` and self-skips there on `process.env.CI`, so it needs no guarding — it handles itself. | | `smoke:tui` | The Ink TUI needs a real TTY. It _is_ invoked in CI via `npm run smoke` and self-skips there on `process.env.CI`, so it needs no guarding — it handles itself. |
So the direction that matters holds: **passing `npm run local:gate` means CI's gates will pass.** The reverse does not. And one CI *invocation* the gate deliberately does not repeat: CI runs each client's unit suite **twice** — bare inside `validate` in the `build` job, instrumented inside `coverage` in the parallel job — at no wall-clock cost, because the two jobs run on separate runners. Serially in one process the **bare** pass was ~80s of a ~370s gate, running exactly the test files the coverage pass runs again a few minutes later ([#2341](https://github.com/modelcontextprotocol/inspector/issues/2341)). So the gate's first stage is **`local:validate`**: the same guards and `validate:core`, then each client's `check` — `format:check` + `lint` + `typecheck`, plus `build` for web, tui and launcher, which name one explicitly; cli's `validate` built only through `test`'s `pretest` hook, so dropping the `test` leg drops that build too, and `coverage:cli` builds the binary once, later — instead of its `validate` (`check` + `test`). The suites still run once, under `coverage`. That subsumes the bare pass because `@vitest/coverage-v8` reads V8's own precise-coverage profiler and rewrites nothing — the code under test is byte-identical, and the instrumented run is only slower, which is the stricter direction for the failures this repo sees. `npm run validate` and both CI jobs are unchanged.
So the direction that matters holds: **passing `npm run local:gate` means every check CI applies has already passed on your machine** — the strongest predictor of a green CI this repo has, though not a proof: CI runs on a different OS, and the bare pass above is the one invocation it has that the gate does not, so a test that passes *only* when instrumentation slows it down would surface there first. That test is a race ([#1596](https://github.com/modelcontextprotocol/inspector/issues/1596)) to fix, not a reason to put the second pass back. The reverse direction does not hold at all: CI green says nothing about the Firefox pass or the real `smoke:tui`.
**The gate is deliberately not named `ci`** ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)). That name collided with `npm ci` — a built-in that clean-installs from the lockfile, does **not** run this script, and is one keystroke away — and it invited the cross-engine passes into GitHub CI by looking like the thing a workflow ought to call. There is **no back-compat alias**: `npm run ci` now fails with npm's missing-script error, which prints the available scripts. **The gate is deliberately not named `ci`** ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)). That name collided with `npm ci` — a built-in that clean-installs from the lockfile, does **not** run this script, and is one keystroke away — and it invited the cross-engine passes into GitHub CI by looking like the thing a workflow ought to call. There is **no back-compat alias**: `npm run ci` now fails with npm's missing-script error, which prints the available scripts.
@ -35,13 +37,14 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor
| `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: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 `// <path>` module banners, so it covers both shapes — a separate `<pkg>-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. | | `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 `// <path>` module banners, so it covers both shapes — a separate `<pkg>-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. |
| `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 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 verify:skills` | Guards `.claude/skills` ([#2163](https://github.com/modelcontextprotocol/inspector/issues/2163)). A skill's body loads only when it is invoked, so one that stops being reachable loses behavior **silently** — and the way that happens is frontmatter Claude Code cannot parse, which loads the body with an *empty* description: `/skill-name` still works, so a manual spot check passes, while nothing is left for the model to match against. This parses each `SKILL.md` the same way, requires an explicit invocation mode on every skill and committed eval cases (positives **and** negatives) on every model-invoked one, and fails when the skill listing exceeds its recorded character budget. Also runs `claude plugin validate` — the authoritative schema — when the installed CLI is **exactly** the pinned version, and otherwise says so and moves on. It runs inside `validate`, which `local:gate` executes *before* the pinned step, so accepting any other local CLI would let it reject skills that CI accepts and exit the gate before the reproducible check ever ran. Runs in `validate`; because that hand-off is best-effort, the authoritative validator also gets a guaranteed step of its own — see `verify:skills:cli` below. | | `npm run verify:skills` | Guards `.claude/skills` ([#2163](https://github.com/modelcontextprotocol/inspector/issues/2163)). A skill's body loads only when it is invoked, so one that stops being reachable loses behavior **silently** — and the way that happens is frontmatter Claude Code cannot parse, which loads the body with an *empty* description: `/skill-name` still works, so a manual spot check passes, while nothing is left for the model to match against. This parses each `SKILL.md` the same way, requires an explicit invocation mode on every skill and committed eval cases (positives **and** negatives) on every model-invoked one, and fails when the skill listing exceeds its recorded character budget. Also runs `claude plugin validate` — the authoritative schema — when the installed CLI is **exactly** the pinned version, and otherwise says so and moves on. It runs inside `validate:guards`, which both `validate` (CI) and `local:validate` (the gate's first stage) execute *before* the pinned step, so accepting any other local CLI would let it reject skills that CI accepts and exit the gate before the reproducible check ever ran. Runs in `validate`; because that hand-off is best-effort, the authoritative validator also gets a guaranteed step of its own — see `verify:skills:cli` below. |
| `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 verify:skills:cli` | Runs `claude plugin validate` — the **authoritative** skill schema — as a guaranteed step, in `local:gate` and in CI. `verify:skills` reimplements the parse and *skips* when the CLI is absent, which is right for `validate` (fast, offline, no Claude Code required) but adds up to "never runs". This one resolves the CLI instead: an installed one **only when it matches the pin exactly**, otherwise the pinned package via `npx -y @anthropic-ai/claude-code@…`. Exact, not a floor: accepting a newer local CLI would validate against a different schema than CI's, so the same `local:gate` could disagree across machines — which is the failure a pin exists to prevent. Needs no authentication. Not in `validate` — it can reach the network. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. `verify-skills.main.test.mjs` drives that guard's `main()` over fixture directories, the sibling guards' `*.main.test.mjs` pattern, so it cannot stop enforcing while the parser tests stay green. `skill-eval.test.mjs` covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. `verify-skills-cli.test.mjs` covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. `claude-cli.test.mjs` covers the one place that decides how the CLI is spawned — the Windows `.cmd` shim needs a shell, and its arguments then need quoting — with the platform **injected**, since a win32-only bug that no runner here executes is exactly the kind that ships. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. `verify-skills.main.test.mjs` drives that guard's `main()` over fixture directories, the sibling guards' `*.main.test.mjs` pattern, so it cannot stop enforcing while the parser tests stay green. `skill-eval.test.mjs` covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. `verify-skills-cli.test.mjs` covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. `claude-cli.test.mjs` covers the one place that decides how the CLI is spawned — the Windows `.cmd` shim needs a shell, and its arguments then need quoting — with the platform **injected**, since a win32-only bug that no runner here executes is exactly the kind that ships. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. 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: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: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 local:gate` | **Mandatory pre-push command.** `validate` → `verify:skills:cli` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. A strict superset of GitHub CI — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. | | `npm run local:gate` | **Mandatory pre-push command.** `local:validate` → `verify:skills:cli` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. Every GitHub CI check plus two local-only ones — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. |
| `npm run local:validate` | The gate's first stage: `validate` with each client's `test` leg removed (#2341) — the same `validate:guards` and `validate:core`, then every client's `check` — `format:check` + `lint` + `typecheck`, plus `build` for web, tui and launcher, which name one explicitly (cli's only validate-time build was `test`'s `pretest` hook, so it goes with the `test` leg and `coverage:cli` builds the binary once, later) — each client's `validate` being `check && test`. The suites run once, under `coverage`, instead of bare here and instrumented there. In the `local:` namespace so the workflow guard keeps it out of CI, where the bare pass is free. |
| `npm run pack:verify` | Publish smoke — see [Publishing](./publishing.md). | | `npm run pack:verify` | Publish smoke — see [Publishing](./publishing.md). |
Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file.

View file

@ -38,7 +38,8 @@
"build:tui": "cd clients/tui && npm run build", "build:tui": "cd clients/tui && npm run build",
"build:web": "cd clients/web && npm run build", "build:web": "cd clients/web && npm run build",
"build:launcher": "cd clients/launcher && npm run build", "build:launcher": "cd clients/launcher && npm run build",
"local:gate": "npm run validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook", "local:gate": "npm run local:validate && npm run verify:skills:cli && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run local:storybook",
"local:validate": "npm run validate:guards && npm run validate:core && cd clients/web && npm run check && cd ../cli && npm run check && cd ../tui && npm run check && cd ../launcher && npm run check",
"local:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "local:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook",
"verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:build-gate": "node scripts/verify-build-gate.mjs",
"verify:bundle-externals": "node scripts/verify-bundle-externals.mjs", "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs",
@ -46,7 +47,8 @@
"verify:skills": "node scripts/verify-skills.mjs", "verify:skills": "node scripts/verify-skills.mjs",
"verify:skills:cli": "node scripts/verify-skills-cli.mjs", "verify:skills:cli": "node scripts/verify-skills-cli.mjs",
"test:scripts": "node --test \"scripts/**/*.test.mjs\"", "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
"validate": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run verify:test-timeouts && 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 validate:guards && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher",
"validate:guards": "npm run verify:format-coverage && npm run verify:skills && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run verify:test-timeouts && npm run test:scripts",
"verify:format-coverage": "node scripts/verify-format-coverage.mjs", "verify:format-coverage": "node scripts/verify-format-coverage.mjs",
"verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs",
"verify:test-timeouts": "node scripts/verify-test-timeouts.mjs", "verify:test-timeouts": "node scripts/verify-test-timeouts.mjs",

View file

@ -25,14 +25,17 @@ export function tokenize(command) {
* <name>` references within a single manifest's `scripts`, plus npm's implicit * <name>` references within a single manifest's `scripts`, plus npm's implicit
* `pre<name>`/`post<name>` lifecycle hooks (npm runs those around `<name>` * `pre<name>`/`post<name>` lifecycle hooks (npm runs those around `<name>`
* without an explicit `npm run`, so a gate moved into e.g. `prevalidate` is * without an explicit `npm run`, so a gate moved into e.g. `prevalidate` is
* still reached). A gate harvested from a script that nothing reachable from * still reached). Every spelling npm accepts for `run` is followed —
* `run-script` is the canonical command and `run`, `rum` and `urn` are its
* aliases — so a chain written as `npm run-script validate:web` reaches the
* same scripts as `npm run validate:web` (Copilot, #2341). A gate harvested from a script that nothing reachable from
* `entry` invokes gates nothing, so callers restrict to this set to assert "CI * `entry` invokes gates nothing, so callers restrict to this set to assert "CI
* actually runs this", not merely "the script exists". * actually runs this", not merely "the script exists".
*/ */
export function reachableScripts(scripts, entry = "validate") { export function reachableScripts(scripts, entry = "validate") {
const reached = new Set(); const reached = new Set();
const queue = [entry]; const queue = [entry];
const runRef = /npm run ([\w:-]+)/g; const runRef = /npm (?:run-script|run|rum|urn) ([\w:-]+)/g;
while (queue.length > 0) { while (queue.length > 0) {
const name = queue.shift(); const name = queue.shift();
if (reached.has(name)) continue; if (reached.has(name)) continue;

View file

@ -48,6 +48,25 @@ test("reachableScripts: follows `npm run` refs and pre/post hooks (r10)", () =>
assert.ok(!reached.has("unrelated")); assert.ok(!reached.has("unrelated"));
}); });
test("reachableScripts: follows every npm spelling of `run` (#2341)", () => {
// `run-script` is the canonical command; `run`, `rum` and `urn` are its
// aliases. A chain written in any of them reaches the same scripts, so a
// guard that only followed `npm run` would let `npm run-script validate:web`
// reintroduce what it exists to forbid (Copilot).
const scripts = {
gate: "npm run-script a && npm rum b && npm urn c && npm run d",
a: "noop",
b: "noop",
c: "noop",
d: "noop",
e: "noop",
};
const reached = reachableScripts(scripts, "gate");
for (const name of ["a", "b", "c", "d"])
assert.ok(reached.has(name), `npm spelling reaching ${name}`);
assert.ok(!reached.has("e"));
});
test("reachableScripts: a `prevalidate`-hosted typecheck is reachable (r10)", () => { test("reachableScripts: a `prevalidate`-hosted typecheck is reachable (r10)", () => {
const scripts = { const scripts = {
validate: "npm run build && npm run test", validate: "npm run build && npm run test",

View file

@ -7,9 +7,11 @@
* two verify gates, `smoke` and the Storybook tests. `npm run smoke` covers * two verify gates, `smoke` and the Storybook tests. `npm run smoke` covers
* `smoke:launcher`, `smoke:cli`, `smoke:tui`, `smoke:web` and * `smoke:launcher`, `smoke:cli`, `smoke:tui`, `smoke:web` and
* `smoke:web:chromium` — all of which BELONG there. * `smoke:web:chromium` — all of which BELONG there.
* - **`npm run local:gate`**, the pre-push gate, is a strict superset: it adds * - **`npm run local:gate`**, the pre-push gate, runs every check CI runs and
* the **Firefox** engine pass, and `smoke:tui` really runs there rather than * adds the **Firefox** engine pass, and `smoke:tui` really runs there rather
* self-skipping. WebKit is on demand and belongs to neither tier. * than self-skipping. WebKit is on demand and belongs to neither tier. It
* runs each client's unit suite once (instrumented, under `coverage`) via
* `local:validate`, where CI's two parallel jobs run it twice (#2341).
* *
* The narrow thing that must never reach CI is a **non-Chromium engine pass**. * The narrow thing that must never reach CI is a **non-Chromium engine pass**.
* `smoke:web:firefox` was placed in the local gate on purpose (#2086, * `smoke:web:firefox` was placed in the local gate on purpose (#2086,

View file

@ -23,6 +23,7 @@ import {
findWorkflowViolations, findWorkflowViolations,
formatWorkflowViolations, formatWorkflowViolations,
} from "./workflow-gate.mjs"; } from "./workflow-gate.mjs";
import { reachableScripts } from "./npm-scripts.mjs";
const repoRoot = join(import.meta.dirname, "..", ".."); const repoRoot = join(import.meta.dirname, "..", "..");
const workflowDir = join(repoRoot, ".github", "workflows"); const workflowDir = join(repoRoot, ".github", "workflows");
@ -609,6 +610,119 @@ describe("the gate's name", () => {
assert.match(scripts["local:gate"], /local:storybook/); assert.match(scripts["local:gate"], /local:storybook/);
}); });
describe("runs each client's test suite once, not twice (#2341)", () => {
// CI runs each client's unit suite bare (inside `validate`) and again
// instrumented (inside `coverage`) on two PARALLEL runners, so the second
// pass is free there. The gate runs serially, where the bare pass was ~80s
// of a ~370s run re-executing exactly the files `coverage` runs a few
// minutes later. So the gate's first stage is `local:validate` — `validate`
// minus every client's `test` leg — and these pin the three properties
// that keep it honest: the gate no longer reaches a client's bare `test`,
// it still reaches every non-test check `validate` reaches, and `validate`
// itself (CI's inner loop) is untouched.
const clients = ["web", "cli", "tui", "launcher"];
const clientScripts = Object.fromEntries(
clients.map((c) => [
c,
JSON.parse(
readFileSync(join(repoRoot, "clients", c, "package.json"), "utf8"),
).scripts,
]),
);
it("starts from `local:validate`, and never from `validate`", () => {
assert.match(scripts["local:gate"], /^npm run local:validate && /);
assert.doesNotMatch(scripts["local:gate"], /\brun validate(?=$|[\s&;])/);
// Nothing reachable from the WHOLE gate — not just its first stage —
// may run `validate` or a client's `validate:*`: those are the doors
// through which the bare `test` leg would come back, and a later stage
// could open one just as easily as the first (Copilot).
const reached = reachableScripts(scripts, "local:gate");
assert.ok(!reached.has("validate"), "local:gate must not reach validate");
for (const c of clients)
assert.ok(
!reached.has(`validate:${c}`),
`local:gate must not reach validate:${c}`,
);
// Each client is visited AND its `check` is the very next command —
// matched together, so dropping one client's `npm run check` while the
// `cd` and another client's `check` remain cannot stay green (Copilot).
for (const c of clients)
assert.match(
scripts["local:validate"],
new RegExp(
`(?:cd (?:\\.\\./|clients/)${c} && npm run check|--prefix clients/${c} run check)(?=$|[\\s&;])`,
),
`local:validate must run \`check\` in clients/${c}`,
);
});
it("shares the guards and `validate:core` with `validate`", () => {
// One list of durable guards, reached by both entry points — so a guard
// added to `validate:guards` lands in CI and in the gate together.
const gate = reachableScripts(scripts, "local:validate");
const inner = reachableScripts(scripts, "validate");
for (const name of ["validate:guards", "validate:core"]) {
assert.ok(gate.has(name), `local:validate must reach ${name}`);
assert.ok(inner.has(name), `validate must reach ${name}`);
}
// Everything `validate` reaches at the root that is not a client
// delegation is reached by the gate too.
for (const name of inner)
if (
name !== "validate" &&
!/^validate:(web|cli|tui|launcher)$/.test(name)
)
assert.ok(gate.has(name), `local:validate must reach ${name}`);
});
it("each client's `validate` is `check` plus `test`, and `check` runs no tests", () => {
for (const c of clients) {
const s = clientScripts[c];
assert.equal(
s.validate,
"npm run check && npm run test",
`clients/${c} validate`,
);
const fromCheck = reachableScripts(s, "check");
for (const name of fromCheck)
assert.doesNotMatch(
name,
/^(pre|post)?test(:|$)/,
`clients/${c} check must not reach ${name}`,
);
// `reachableScripts` follows `npm run <name>` only, so also reject the
// spellings that reach the suite without one: a direct `vitest`, and
// npm's built-in `npm test` / `npm t` / `npm tst` aliases (Copilot).
const checkCommands = [...fromCheck].map((n) => s[n]).join(" ");
assert.doesNotMatch(
checkCommands,
/\bvitest\b/,
`clients/${c} check must not invoke vitest`,
);
assert.doesNotMatch(
checkCommands,
/\bnpm (?:test|t|tst)(?=$|[\s&;])/,
`clients/${c} check must not invoke \`npm test\``,
);
// And `check` is the whole of `validate` minus the test leg: what
// `validate` reaches is exactly itself, plus what `check` reaches, plus
// what `test` reaches (its `pretest` build preambles included).
assert.deepEqual(
[...reachableScripts(s, "validate")].sort(),
[
...new Set([
"validate",
...fromCheck,
...reachableScripts(s, "test"),
]),
].sort(),
`clients/${c} validate must reach exactly check + test`,
);
}
});
});
it("has no `ci` alias, not even a deprecated one", () => { it("has no `ci` alias, not even a deprecated one", () => {
// The rename is only worth anything if the old name is GONE (#2146): an // The rename is only worth anything if the old name is GONE (#2146): an
// alias preserves exactly the association being scrubbed, and leaves // alias preserves exactly the association being scrubbed, and leaves

View file

@ -350,8 +350,9 @@ if (!output.includes(ERROR_PREFIX)) {
// so PROBE_MODULE must appear. Without this, a repo that already leaks a built-in // so PROBE_MODULE must appear. Without this, a repo that already leaks a built-in
// would report OK even if the probe was tree-shaken or entryPath went stale — // would report OK even if the probe was tree-shaken or entryPath went stale —
// branch (c)'s failure, silently inverted into a pass. (Unreachable inside // branch (c)'s failure, silently inverted into a pass. (Unreachable inside
// `npm run local:gate`: `validate`'s `build:web` fails first on a pre-existing leak — but // `npm run local:gate`: web's `check` — its `build`, run by `local:validate` —
// this script is a documented standalone command, run exactly when debugging one.) // fails first on a pre-existing leak; but this script is a documented
// standalone command, run exactly when debugging one.)
if (!output.includes(PROBE_MODULE)) { if (!output.includes(PROBE_MODULE)) {
fail( fail(
`the #1769 gate fired, but ${PROBE_MODULE} isn't among the offenders — it ` + `the #1769 gate fired, but ${PROBE_MODULE} isn't among the offenders — it ` +

View file

@ -330,7 +330,7 @@ function main(argv = process.argv.slice(2)) {
// The authoritative parse, when it is available — strictly a bonus, and only // The authoritative parse, when it is available — strictly a bonus, and only
// from the EXACT pinned CLI. // from the EXACT pinned CLI.
// //
// `local:gate` runs `validate` (and therefore this) before the pinned // `local:gate` runs `local:validate` (and therefore this) before the pinned
// `verify:skills:cli` step, so a local CLI on any other version could reject // `verify:skills:cli` step, so a local CLI on any other version could reject
// skills that CI's pinned validator accepts — and the gate would exit here, // skills that CI's pinned validator accepts — and the gate would exit here,
// never reaching the reproducible step (Copilot). Accepting merely "new enough" // never reaching the reproducible step (Copilot). Accepting merely "new enough"