From 09bdac406691c7103404d4d118c2831c43a606ac Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 22:59:27 -0400 Subject: [PATCH 01/39] test(cli,tui): typecheck the __tests__ dirs (#1791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cli/tui `typecheck` scripts only checked `src` (the src-only tsconfig excludes `**/*.test.*`), leaving the `__tests__` dirs — which do the most `as` casting — with no tsc pass. Add a `tsconfig.test.json` per client that includes `__tests__` with the test-exclusions dropped and the test-only path aliases (`@modelcontextprotocol/inspector-test-server`, the `@inspector/core/*` deep paths, express/vitest) resolving what vitest resolves, then fold `tsc -p tsconfig.test.json` into each `typecheck`. Fixes the 76 errors it surfaced: - cli: a wrong `@modelcontextprotocol/inspector-core/...` module specifier (should be `@inspector/core/...`; only a type import so vitest never caught it), `r.headers` optional-access guards, and partial OAuth mocks replaced with typed `makeFakeCliOAuthClient()` / `makeFakeServerSettings()` factories instead of `as` casts. Adds `@types/express` (devDep) so the transitively aliased test-server source typechecks, mirroring clients/web. - tui: rest-param signatures on the App OAuth spies (fixes the spread-arg and narrow-return errors), a widened callback param (`iss?`), a `makeFakeServer- Settings()` factory for the tuiOAuth tests, required OAuth-metadata/client fields in AuthTab, and a loosened `entry()` message param for the deliberately-malformed wire messages HistoryTab's defensive branches render. Gate coverage only; no runtime behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/__tests__/cli.test.ts | 6 +- clients/cli/__tests__/cliOAuth.test.ts | 76 ++++++++-------- clients/cli/__tests__/helpers/fixtures.ts | 2 +- .../cli/__tests__/helpers/oauth-test-fakes.ts | 54 +++++++++++ .../cli/__tests__/oauth-interactive.test.ts | 24 ++--- clients/cli/package-lock.json | 89 +++++++++++++++++++ clients/cli/package.json | 3 +- clients/cli/tsconfig.test.json | 21 +++++ clients/tui/__tests__/App.test.tsx | 27 ++++-- clients/tui/__tests__/AuthTab.test.tsx | 4 + clients/tui/__tests__/HistoryTab.test.tsx | 9 +- .../tui/__tests__/helpers/server-settings.ts | 27 ++++++ clients/tui/__tests__/tuiOAuth.test.ts | 9 +- clients/tui/package.json | 2 +- clients/tui/tsconfig.test.json | 17 ++++ 15 files changed, 300 insertions(+), 70 deletions(-) create mode 100644 clients/cli/__tests__/helpers/oauth-test-fakes.ts create mode 100644 clients/cli/tsconfig.test.json create mode 100644 clients/tui/__tests__/helpers/server-settings.ts create mode 100644 clients/tui/tsconfig.test.json diff --git a/clients/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts index ca3931008..1cbf18a8e 100644 --- a/clients/cli/__tests__/cli.test.ts +++ b/clients/cli/__tests__/cli.test.ts @@ -22,7 +22,7 @@ import { createEchoTool, createTestServerInfo, } from "@modelcontextprotocol/inspector-test-server"; -import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/index.js"; describe("CLI Tests", () => { describe("Basic CLI Mode", () => { @@ -368,7 +368,7 @@ describe("CLI Tests", () => { // the config file into the connection's settings. const sawHeader = server .getRecordedRequests() - .some((r) => r.headers["x-custom-token"] === "secret-123"); + .some((r) => r.headers?.["x-custom-token"] === "secret-123"); expect(sawHeader).toBe(true); } finally { await server.stop(); @@ -409,7 +409,7 @@ describe("CLI Tests", () => { expectCliSuccess(result); const tokens = server .getRecordedRequests() - .map((r) => r.headers["x-custom-token"]); + .map((r) => r.headers?.["x-custom-token"]); expect(tokens).toContain("from-cli"); expect(tokens).not.toContain("from-disk"); } finally { diff --git a/clients/cli/__tests__/cliOAuth.test.ts b/clients/cli/__tests__/cliOAuth.test.ts index f18367be6..70738d784 100644 --- a/clients/cli/__tests__/cliOAuth.test.ts +++ b/clients/cli/__tests__/cliOAuth.test.ts @@ -13,6 +13,10 @@ import { } from "../src/cliOAuth.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import { createInterface } from "node:readline/promises"; +import { + makeFakeCliOAuthClient, + makeFakeServerSettings, +} from "./helpers/oauth-test-fakes.js"; // `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from // stdin via node:readline/promises. Mock the module so the default path can be @@ -103,7 +107,7 @@ describe("cliOAuth", () => { expect( isStandardOAuthStepUp( { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, - {}, + makeFakeServerSettings(), ), ).toBe(true); }); @@ -112,7 +116,7 @@ describe("cliOAuth", () => { expect( isStandardOAuthStepUp( { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, - { enterpriseManaged: true }, + makeFakeServerSettings({ enterpriseManaged: true }), ), ).toBe(false); }); @@ -220,7 +224,7 @@ describe("cliOAuth", () => { error, new MutableRedirectUrlProvider(), { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, - {}, + makeFakeServerSettings(), { confirmStepUp: async () => false, isTTY: true }, ), ).rejects.toThrow("Step-up authorization declined."); @@ -250,7 +254,7 @@ describe("cliOAuth", () => { error, new MutableRedirectUrlProvider(), { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, - {}, + makeFakeServerSettings(), { confirmStepUp: async () => true, isTTY: true }, ); @@ -338,7 +342,7 @@ describe("cliOAuth", () => { OAUTH_HTTP_CONFIG, new MutableRedirectUrlProvider(), { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, - { enterpriseManaged: true }, + makeFakeServerSettings({ enterpriseManaged: true }), fn, { confirmStepUp: async () => true, isTTY: true }, ); @@ -375,7 +379,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ); @@ -395,7 +399,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ); @@ -412,7 +416,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ), ).rejects.toThrow("Step-up authorization declined."); @@ -431,7 +435,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ), ).rejects.toThrow("Step-up authorization declined."); @@ -455,7 +459,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ), ).rejects.toThrow("Step-up authorization declined."); @@ -482,7 +486,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ); @@ -503,7 +507,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), { isTTY: false }, ); expect(runSpy).toHaveBeenCalled(); @@ -522,7 +526,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), { isTTY: true, stepUpPromptTimeoutMs: 20 }, ), ).rejects.toMatchObject({ @@ -556,7 +560,7 @@ describe("cliOAuth", () => { standardStepUpError(), new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, - {}, + makeFakeServerSettings(), INTERACTIVE, ); let settled = false; @@ -596,11 +600,10 @@ describe("cliOAuth", () => { ), ) .mockResolvedValueOnce(undefined); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), - }; + }); await connectInspectorWithOAuth( client, @@ -626,11 +629,10 @@ describe("cliOAuth", () => { ), ) .mockResolvedValueOnce(undefined); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), - }; + }); await connectInspectorWithOAuth( client, @@ -654,11 +656,11 @@ describe("cliOAuth", () => { .mockRejectedValueOnce(new Error("Connection failed for server (401)")) .mockResolvedValueOnce(undefined); // A rejecting disconnect exercises the `.catch(() => {})` guard. - const client = { + const client = makeFakeCliOAuthClient({ connect, disconnect: vi.fn().mockRejectedValue(new Error("disconnect failed")), checkAuthChallengeSatisfied: vi.fn(), - }; + }); await connectInspectorWithOAuth( client, @@ -679,11 +681,10 @@ describe("cliOAuth", () => { const connect = vi .fn() .mockRejectedValue(new Error("some unrelated failure")); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn(), - }; + }); await expect( connectInspectorWithOAuth( @@ -698,11 +699,10 @@ describe("cliOAuth", () => { it("rethrows when the server config is not OAuth-capable", async () => { const connect = vi.fn().mockRejectedValue(new Error("nope (401)")); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn(), - }; + }); await expect( connectInspectorWithOAuth( @@ -721,11 +721,10 @@ describe("cliOAuth", () => { reason: "token_expired", }), ); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), - }; + }); await expect( connectInspectorWithOAuth( @@ -751,11 +750,10 @@ describe("cliOAuth", () => { ), ) .mockResolvedValueOnce(undefined); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), - }; + }); await connectInspectorWithOAuth( client, @@ -775,11 +773,10 @@ describe("cliOAuth", () => { const connect = vi .fn() .mockRejectedValue(new Error("Connection failed for server (401)")); - const client = { + const client = makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn(), - }; + }); await expect( connectInspectorWithOAuth( @@ -1101,11 +1098,10 @@ describe("cliOAuth", () => { const connect = vi.fn().mockRejectedValue(err); await expect( connectInspectorWithOAuth( - { + makeFakeCliOAuthClient({ connect, - disconnect: vi.fn(), checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), - }, + }), OAUTH_HTTP_CONFIG, new MutableRedirectUrlProvider(), CALLBACK_URL_CONFIG, diff --git a/clients/cli/__tests__/helpers/fixtures.ts b/clients/cli/__tests__/helpers/fixtures.ts index 457986a5c..abb25f519 100644 --- a/clients/cli/__tests__/helpers/fixtures.ts +++ b/clients/cli/__tests__/helpers/fixtures.ts @@ -3,7 +3,7 @@ import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; -import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/index.js"; /** * Sentinel value for tests that don't need a real server diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts new file mode 100644 index 000000000..7350b8aff --- /dev/null +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -0,0 +1,54 @@ +import { vi } from "vitest"; +import { + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, +} from "@inspector/core/mcp/types.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import type { CliOAuthClient } from "../../src/cliOAuth.js"; + +/** + * Typed mock factories for the CLI OAuth tests. They exist so a test can supply + * only the surface a given code path exercises while `tsc` still sees a + * complete `CliOAuthClient` / `InspectorServerSettings` — avoiding a spray of + * `as unknown as` casts (see #1791 and the AGENTS.md `as`-cast policy). + */ + +/** + * A full {@link CliOAuthClient} whose every method is a `vi.fn()` resolving to a + * benign value. Pass `overrides` to swap in the method(s) a test drives (e.g. a + * `connect` that rejects with `AuthRecoveryRequiredError`). + */ +export function makeFakeCliOAuthClient( + overrides: Partial = {}, +): CliOAuthClient { + return { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + authenticate: vi.fn().mockResolvedValue(undefined), + beginInteractiveAuthorization: vi.fn().mockResolvedValue(undefined), + completeOAuthFlow: vi.fn().mockResolvedValue(undefined), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), + ...overrides, + }; +} + +/** + * A full {@link InspectorServerSettings} carrying the same product defaults the + * server-list loader applies (`storedFieldsToInspectorSettings`). Pass + * `overrides` for the field(s) under test (e.g. `{ enterpriseManaged: true }`). + */ +export function makeFakeServerSettings( + overrides: Partial = {}, +): InspectorServerSettings { + return { + headers: [], + metadata: [], + env: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + roots: [], + ...overrides, + }; +} diff --git a/clients/cli/__tests__/oauth-interactive.test.ts b/clients/cli/__tests__/oauth-interactive.test.ts index 0508c5ec2..db1cad720 100644 --- a/clients/cli/__tests__/oauth-interactive.test.ts +++ b/clients/cli/__tests__/oauth-interactive.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -21,6 +21,7 @@ import { withCliAuthRecoveryRetry, } from "../src/cliOAuth.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { makeFakeServerSettings } from "./helpers/oauth-test-fakes.js"; const oauthTestStatePath = join( tmpdir(), @@ -216,15 +217,9 @@ describe("CLI interactive OAuth (integration)", () => { const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); expect(getTempTool).toBeDefined(); - let stepUpPrompted = false; - const originalWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = ((chunk, ...rest) => { - const text = typeof chunk === "string" ? chunk : String(chunk); - if (text.includes("Proceed with step-up authorization?")) { - stepUpPrompted = true; - } - return originalWrite(chunk, ...rest); - }) as typeof process.stderr.write; + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); try { const result = await withCliAuthRecoveryRetry( @@ -232,7 +227,7 @@ describe("CLI interactive OAuth (integration)", () => { { type: "streamable-http", url: `${serverUrl}/mcp` }, redirectUrlProvider, callbackUrlConfig, - {}, + makeFakeServerSettings(), () => client.callTool(getTempTool!, { city: "NYC", @@ -241,10 +236,15 @@ describe("CLI interactive OAuth (integration)", () => { { confirmStepUp: async () => true, isTTY: true }, ); + const stepUpPrompted = stderrSpy.mock.calls.some( + ([chunk]) => + typeof chunk === "string" && + chunk.includes("Proceed with step-up authorization?"), + ); expect(stepUpPrompted).toBe(true); expect(result.success).toBe(true); } finally { - process.stderr.write = originalWrite; + stderrSpy.mockRestore(); await client.disconnect(); } }, 30_000); diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ccecd55f5..d9a12d310 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/express": "^5.0.6", "@types/node": "^24.12.4", "@vitest/coverage-v8": "^4.1.0", "eslint": "^9.39.4", @@ -1815,6 +1816,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1826,6 +1838,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1840,6 +1862,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1857,6 +1911,41 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 221215ff0..eb37684fb 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -16,7 +16,7 @@ ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit -p tsconfig.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", "test": "vitest run", "test:watch": "vitest", @@ -47,6 +47,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/express": "^5.0.6", "@types/node": "^24.12.4", "@vitest/coverage-v8": "^4.1.0", "eslint": "^9.39.4", diff --git a/clients/cli/tsconfig.test.json b/clients/cli/tsconfig.test.json new file mode 100644 index 000000000..5b0fcda84 --- /dev/null +++ b/clients/cli/tsconfig.test.json @@ -0,0 +1,21 @@ +{ + // Typecheck the __tests__ dir (the src-only tsconfig.json excludes tests). + // Mirrors clients/web/tsconfig.test.json: the test-server barrel is aliased + // to its source and the module paths below resolve what vitest resolves via + // vitest.shared.mts's projectResolve, so tsc validates the tests against the + // same graph the runner executes. See #1791. + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "express"], + "paths": { + "@inspector/core/*": ["../../core/*"], + "@modelcontextprotocol/inspector-test-server": [ + "../../test-servers/src/index.ts" + ], + "express": ["./node_modules/@types/express"], + "vitest": ["./node_modules/vitest"] + } + }, + "include": ["src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 5fd2c5e39..087333fb8 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -48,21 +48,33 @@ const h = vi.hoisted(() => { const openUrl = vi.fn().mockResolvedValue(undefined); // Shared OAuth-related spies so a test can configure resolve/reject and // assert calls regardless of which per-server FakeClient instance App built. + // The OAuth-lifecycle spies are given explicit `(...a: unknown[]) => …` + // signatures so the FakeClient wrappers below (which forward `...a`) typecheck + // against them, and so a wide return type (e.g. AuthChallengeOutcome) lets a + // test `mockResolvedValue` any variant rather than only the impl's literal. const clientSpies = { - authenticate: vi.fn( - async (): Promise => "https://auth.example/start", + authenticate: vi.fn<(...a: unknown[]) => Promise>( + async () => "https://auth.example/start", ), clearOAuthTokens: vi.fn(), - completeOAuthFlow: vi.fn(async (): Promise => {}), - getOAuthState: vi.fn(async () => undefined), + completeOAuthFlow: vi.fn<(...a: unknown[]) => Promise>( + async () => {}, + ), + getOAuthState: vi.fn<(...a: unknown[]) => Promise>( + async () => undefined, + ), callTool: vi.fn(), - checkAuthChallengeSatisfied: vi.fn(async () => false), - handleAuthChallenge: vi.fn(async () => ({ kind: "satisfied" as const })), + checkAuthChallengeSatisfied: vi.fn<(...a: unknown[]) => Promise>( + async () => false, + ), + handleAuthChallenge: vi.fn< + (...a: unknown[]) => Promise + >(async () => ({ kind: "satisfied" })), }; // Captured options from the most recent callbackServer.start(), so a test can // drive the onCallback / onError handlers the OAuth flows register. interface CallbackOpts { - onCallback: (p: { code: string }) => Promise | void; + onCallback: (p: { code: string; iss?: string }) => Promise | void; onError: (p: { error?: string; error_description?: string }) => void; } const cb: { opts: CallbackOpts | null } = { opts: null }; @@ -268,6 +280,7 @@ import { AuthRecoveryRequiredError, EMA_STEP_UP_PENDING_URL, } from "@inspector/core/auth/challenge.js"; +import type { AuthChallengeOutcome } from "@inspector/core/auth/challenge.js"; import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index d12f16f39..86d6b18eb 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -26,13 +26,17 @@ const sampleOAuthState: OAuthConnectionState = { client: { clientId: "abc123", registrationKind: "dcr", + hasClientSecret: false, }, tokens: { access_token: "tok-abcdefghijklmnopqrstuvwxyz", token_type: "Bearer", }, authorizationServerMetadata: { + issuer: "https://auth.example.com", authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], }, configuredScope: "read write", }; diff --git a/clients/tui/__tests__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx index 72c53dc82..056268417 100644 --- a/clients/tui/__tests__/HistoryTab.test.tsx +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -28,7 +28,14 @@ const PAGE_DOWN = `${ESC}[6~`; const ts = new Date("2024-01-01T12:34:56Z"); -const entry = (over: Partial): MessageEntry => +// `message` is deliberately typed `unknown`: several entries below construct +// malformed wire messages (a response with neither result nor error; a +// notification with no method) to exercise HistoryTab's defensive rendering +// branches, which the strict JSONRPCMessage union can't represent. The return +// cast is the existing scaffolding cast that carries those shapes through. +const entry = ( + over: Omit, "message"> & { message?: unknown }, +): MessageEntry => ({ id: "id", timestamp: ts, diff --git a/clients/tui/__tests__/helpers/server-settings.ts b/clients/tui/__tests__/helpers/server-settings.ts new file mode 100644 index 000000000..873cfcda0 --- /dev/null +++ b/clients/tui/__tests__/helpers/server-settings.ts @@ -0,0 +1,27 @@ +import { + DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_TASK_TTL_MS, +} from "@inspector/core/mcp/types.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; + +/** + * A full {@link InspectorServerSettings} carrying the same product defaults the + * server-list loader applies. Lets a test supply only the field(s) under test + * (e.g. `{ enterpriseManaged: true }`) while `tsc` still sees a complete object, + * avoiding `as unknown as` casts (see #1791 and the AGENTS.md `as`-cast policy). + */ +export function makeFakeServerSettings( + overrides: Partial = {}, +): InspectorServerSettings { + return { + headers: [], + metadata: [], + env: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: DEFAULT_TASK_TTL_MS, + maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, + roots: [], + ...overrides, + }; +} diff --git a/clients/tui/__tests__/tuiOAuth.test.ts b/clients/tui/__tests__/tuiOAuth.test.ts index 125c57f4b..8aaf1da72 100644 --- a/clients/tui/__tests__/tuiOAuth.test.ts +++ b/clients/tui/__tests__/tuiOAuth.test.ts @@ -5,19 +5,20 @@ import { stepUpConfirmMessage, stepUpInsufficientScopeMessage, } from "../src/utils/tuiOAuth.js"; +import { makeFakeServerSettings } from "./helpers/server-settings.js"; describe("tuiOAuth", () => { it("detects standard OAuth step-up", () => { expect( isStandardOAuthStepUp( { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, - { enterpriseManaged: false }, + makeFakeServerSettings({ enterpriseManaged: false }), ), ).toBe(true); expect( isStandardOAuthStepUp( { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, - { enterpriseManaged: true }, + makeFakeServerSettings({ enterpriseManaged: true }), ), ).toBe(false); }); @@ -26,13 +27,13 @@ describe("tuiOAuth", () => { expect( isStepUpConfirmation( { reason: "insufficient_scope", requiredScopes: ["env:read"] }, - { enterpriseManaged: true }, + makeFakeServerSettings({ enterpriseManaged: true }), ), ).toBe(true); expect( isStepUpConfirmation( { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, - { enterpriseManaged: false }, + makeFakeServerSettings({ enterpriseManaged: false }), ), ).toBe(true); }); diff --git a/clients/tui/package.json b/clients/tui/package.json index 005801f47..7799b1f7d 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -18,7 +18,7 @@ "scripts": { "dev": "vite-node --config vitest.config.ts dev.ts", "build": "tsup", - "typecheck": "tsc --noEmit -p tsconfig.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", "test": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/clients/tui/tsconfig.test.json b/clients/tui/tsconfig.test.json new file mode 100644 index 000000000..320d629c8 --- /dev/null +++ b/clients/tui/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + // Typecheck the __tests__ dir (the src-only tsconfig.json excludes tests). + // The tui tests don't import the test-server package, so this only re-adds + // the `@inspector/core/*` deep-path resolution and the react redirects the + // base config already carries, extended to include __tests__. See #1791. + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@inspector/core/*": ["../../core/*"], + "react": ["./node_modules/@types/react"], + "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"], + "vitest": ["./node_modules/vitest"] + } + }, + "include": ["index.ts", "tui.tsx", "src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "build"] +} From 373f8e9da61b5476efd06dd38e5bb4ce8b028314 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:24:12 -0400 Subject: [PATCH 02/39] =?UTF-8?q?test(cli,tui):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20real=20spy=20signatures,=20config=20typecheck,=20ni?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the @claude review of #1799: - App.test.tsx: type the OAuth spies against the real InspectorClient method signatures (`vi.fn()`) and forward `Parameters<…>` tuples from the FakeClient wrappers, so `toHaveBeenCalledWith(...)` on the six OAuth-lifecycle spies stays argument-checked under the new gate rather than accepting anything via `unknown[]`. `getOAuthState` regains its wide `OAuthConnectionState | undefined` return. - Typecheck the client config files too: add `vitest.config.ts` / `tsup.config.ts` (and tui `dev.ts`) to each src `tsconfig.json` include — they were the one first-party surface still getting no tsc pass. - Trim the redundant `src/**/*` from each `tsconfig.test.json` include (tsc pulls in the src the tests import; the src-only config already checks it). - Rename the tui settings helper to `oauth-test-fakes.ts` to match cli's, and soften both factories' docstrings to stop naming a loader they don't mirror field-for-field. - Fix a stale `@modelcontextprotocol/inspector-core/...` specifier in a createRemoteTransport JSDoc example (the same wrong package name the gate caught in the cli tests, surviving in a comment). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .../cli/__tests__/helpers/oauth-test-fakes.ts | 5 +- clients/cli/tsconfig.json | 2 +- clients/cli/tsconfig.test.json | 5 +- clients/tui/__tests__/App.test.tsx | 51 ++++++++++--------- ...server-settings.ts => oauth-test-fakes.ts} | 10 ++-- clients/tui/__tests__/tuiOAuth.test.ts | 2 +- clients/tui/tsconfig.json | 9 +++- clients/tui/tsconfig.test.json | 6 ++- core/mcp/remote/createRemoteTransport.ts | 2 +- 9 files changed, 57 insertions(+), 35 deletions(-) rename clients/tui/__tests__/helpers/{server-settings.ts => oauth-test-fakes.ts} (55%) diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts index 7350b8aff..923ba34d2 100644 --- a/clients/cli/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -33,8 +33,9 @@ export function makeFakeCliOAuthClient( } /** - * A full {@link InspectorServerSettings} carrying the same product defaults the - * server-list loader applies (`storedFieldsToInspectorSettings`). Pass + * A full {@link InspectorServerSettings} with representative defaults for its + * required fields (the timeouts at 0 = "SDK default", `taskTtl` / + * `maxFetchRequests` at their product defaults, empty lists elsewhere). Pass * `overrides` for the field(s) under test (e.g. `{ enterpriseManaged: true }`). */ export function makeFakeServerSettings( diff --git a/clients/cli/tsconfig.json b/clients/cli/tsconfig.json index 522a3ebe2..69df707ec 100644 --- a/clients/cli/tsconfig.json +++ b/clients/cli/tsconfig.json @@ -20,6 +20,6 @@ "@inspector/core/*": ["../../core/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*", "vitest.config.ts", "tsup.config.ts"], "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"] } diff --git a/clients/cli/tsconfig.test.json b/clients/cli/tsconfig.test.json index 5b0fcda84..6b5dca619 100644 --- a/clients/cli/tsconfig.test.json +++ b/clients/cli/tsconfig.test.json @@ -16,6 +16,9 @@ "vitest": ["./node_modules/vitest"] } }, - "include": ["src/**/*", "__tests__/**/*"], + // Only the tests root the project; tsc pulls in the `src` they import. The + // src-only tsconfig.json already validates all of `src` (without the + // test-only aliases), so listing it here too would just check it twice. + "include": ["__tests__/**/*"], "exclude": ["node_modules", "build"] } diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 087333fb8..b8c280655 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -48,28 +48,30 @@ const h = vi.hoisted(() => { const openUrl = vi.fn().mockResolvedValue(undefined); // Shared OAuth-related spies so a test can configure resolve/reject and // assert calls regardless of which per-server FakeClient instance App built. - // The OAuth-lifecycle spies are given explicit `(...a: unknown[]) => …` - // signatures so the FakeClient wrappers below (which forward `...a`) typecheck - // against them, and so a wide return type (e.g. AuthChallengeOutcome) lets a - // test `mockResolvedValue` any variant rather than only the impl's literal. + // Each spy is typed against the real InspectorClient method signature so that + // `toHaveBeenCalledWith(...)` assertions against them stay argument-checked + // under the new gate — and the FakeClient wrappers below forward the same + // `Parameters<…>` tuple, which spreads cleanly (a tuple, not `unknown[]`). + // `authenticate` keeps a string-returning stub (the real method returns a + // `URL`); it takes no arguments, so there is nothing to arg-check there. const clientSpies = { - authenticate: vi.fn<(...a: unknown[]) => Promise>( + authenticate: vi.fn<() => Promise>( async () => "https://auth.example/start", ), - clearOAuthTokens: vi.fn(), - completeOAuthFlow: vi.fn<(...a: unknown[]) => Promise>( + clearOAuthTokens: vi.fn(), + completeOAuthFlow: vi.fn( async () => {}, ), - getOAuthState: vi.fn<(...a: unknown[]) => Promise>( + getOAuthState: vi.fn( async () => undefined, ), callTool: vi.fn(), - checkAuthChallengeSatisfied: vi.fn<(...a: unknown[]) => Promise>( - async () => false, + checkAuthChallengeSatisfied: vi.fn< + InspectorClient["checkAuthChallengeSatisfied"] + >(async () => false), + handleAuthChallenge: vi.fn( + async () => ({ kind: "satisfied" }), ), - handleAuthChallenge: vi.fn< - (...a: unknown[]) => Promise - >(async () => ({ kind: "satisfied" })), }; // Captured options from the most recent callbackServer.start(), so a test can // drive the onCallback / onError handlers the OAuth flows register. @@ -136,16 +138,19 @@ const h = vi.hoisted(() => { | "sse" | "streamable-http", ); - authenticate = (...a: unknown[]) => clientSpies.authenticate(...a); - clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a); - completeOAuthFlow = (...a: unknown[]) => - clientSpies.completeOAuthFlow(...a); - getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a); + authenticate = () => clientSpies.authenticate(); + clearOAuthTokens = () => clientSpies.clearOAuthTokens(); + completeOAuthFlow = ( + ...a: Parameters + ) => clientSpies.completeOAuthFlow(...a); + getOAuthState = () => clientSpies.getOAuthState(); callTool = (...a: unknown[]) => clientSpies.callTool(...a); - checkAuthChallengeSatisfied = (...a: unknown[]) => - clientSpies.checkAuthChallengeSatisfied(...a); - handleAuthChallenge = (...a: unknown[]) => - clientSpies.handleAuthChallenge(...a); + checkAuthChallengeSatisfied = ( + ...a: Parameters + ) => clientSpies.checkAuthChallengeSatisfied(...a); + handleAuthChallenge = ( + ...a: Parameters + ) => clientSpies.handleAuthChallenge(...a); readResource = vi.fn(async () => ({ result: { contents: [{ uri: "file://x", text: "hello" }] }, })); @@ -275,12 +280,12 @@ vi.mock("../src/utils/openUrl.js", () => ({ })); import App from "../src/App.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; import type { TuiServer } from "../src/tui-servers.js"; import { AuthRecoveryRequiredError, EMA_STEP_UP_PENDING_URL, } from "@inspector/core/auth/challenge.js"; -import type { AuthChallengeOutcome } from "@inspector/core/auth/challenge.js"; import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); diff --git a/clients/tui/__tests__/helpers/server-settings.ts b/clients/tui/__tests__/helpers/oauth-test-fakes.ts similarity index 55% rename from clients/tui/__tests__/helpers/server-settings.ts rename to clients/tui/__tests__/helpers/oauth-test-fakes.ts index 873cfcda0..2a69d236a 100644 --- a/clients/tui/__tests__/helpers/server-settings.ts +++ b/clients/tui/__tests__/helpers/oauth-test-fakes.ts @@ -5,10 +5,12 @@ import { import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; /** - * A full {@link InspectorServerSettings} carrying the same product defaults the - * server-list loader applies. Lets a test supply only the field(s) under test - * (e.g. `{ enterpriseManaged: true }`) while `tsc` still sees a complete object, - * avoiding `as unknown as` casts (see #1791 and the AGENTS.md `as`-cast policy). + * A full {@link InspectorServerSettings} with representative defaults for its + * required fields (the timeouts at 0 = "SDK default", `taskTtl` / + * `maxFetchRequests` at their product defaults, empty lists elsewhere). Lets a + * test supply only the field(s) under test (e.g. `{ enterpriseManaged: true }`) + * while `tsc` still sees a complete object, avoiding `as unknown as` casts (see + * #1791 and the AGENTS.md `as`-cast policy). */ export function makeFakeServerSettings( overrides: Partial = {}, diff --git a/clients/tui/__tests__/tuiOAuth.test.ts b/clients/tui/__tests__/tuiOAuth.test.ts index 8aaf1da72..92da7d7c9 100644 --- a/clients/tui/__tests__/tuiOAuth.test.ts +++ b/clients/tui/__tests__/tuiOAuth.test.ts @@ -5,7 +5,7 @@ import { stepUpConfirmMessage, stepUpInsufficientScopeMessage, } from "../src/utils/tuiOAuth.js"; -import { makeFakeServerSettings } from "./helpers/server-settings.js"; +import { makeFakeServerSettings } from "./helpers/oauth-test-fakes.js"; describe("tuiOAuth", () => { it("detects standard OAuth step-up", () => { diff --git a/clients/tui/tsconfig.json b/clients/tui/tsconfig.json index 1d5ac9cac..511f73083 100644 --- a/clients/tui/tsconfig.json +++ b/clients/tui/tsconfig.json @@ -25,6 +25,13 @@ "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"] } }, - "include": ["index.ts", "tui.tsx", "src/**/*"], + "include": [ + "index.ts", + "tui.tsx", + "src/**/*", + "dev.ts", + "vitest.config.ts", + "tsup.config.ts" + ], "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"] } diff --git a/clients/tui/tsconfig.test.json b/clients/tui/tsconfig.test.json index 320d629c8..a1cd82c9b 100644 --- a/clients/tui/tsconfig.test.json +++ b/clients/tui/tsconfig.test.json @@ -12,6 +12,10 @@ "vitest": ["./node_modules/vitest"] } }, - "include": ["index.ts", "tui.tsx", "src/**/*", "__tests__/**/*"], + // Only the tests root the project; tsc pulls in the `src` they import. The + // src-only tsconfig.json already validates `src` (and index.ts / tui.tsx) + // without the test-only aliases, so listing them here too would just check + // them twice. + "include": ["__tests__/**/*"], "exclude": ["node_modules", "build"] } diff --git a/core/mcp/remote/createRemoteTransport.ts b/core/mcp/remote/createRemoteTransport.ts index 5e720c530..96803d8b2 100644 --- a/core/mcp/remote/createRemoteTransport.ts +++ b/core/mcp/remote/createRemoteTransport.ts @@ -26,7 +26,7 @@ export interface RemoteTransportFactoryOptions { * connecting to the given remote server. * * @example - * import { API_SERVER_ENV_VARS } from '@modelcontextprotocol/inspector-core/mcp/remote'; + * import { API_SERVER_ENV_VARS } from '@inspector/core/mcp/remote/constants.js'; * const createTransport = createRemoteTransport({ * baseUrl: 'http://localhost:3000', * authToken: process.env[API_SERVER_ENV_VARS.AUTH_TOKEN], From 37e174e3a636c689c9e7f72fccbee6fec71341a0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:41:52 -0400 Subject: [PATCH 03/39] =?UTF-8?q?test:=20address=20round-2=20review=20?= =?UTF-8?q?=E2=80=94=20correct=20claims,=20tighten=20types,=20typecheck=20?= =?UTF-8?q?launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-2 @claude review of #1799: - Correct the toHaveBeenCalledWith claim: vitest doesn't type-check `toHaveBeenCalledWith(...)` arguments against the mock's signature, so the App.test.tsx comment no longer claims it does — the typed spies buy checked implementations/return payloads (the stale-literal fix), not arg-checking. - authenticate spy now uses the real `InspectorClient["authenticate"]` signature (returns a `URL`), so it no longer diverges from production; the one mockResolvedValue is updated to a URL. - makeFakeCliOAuthClient members are typed against the real CliOAuthClient signatures (not bare vi.fn()), so a wrong-signature override is rejected. - HistoryTab: revert the `entry()` `message: unknown` widening (it un-checked the ~15 well-formed fixtures) and instead scope a named `MALFORMED` cast to the two intentionally-malformed messages. - Typecheck the launcher `__tests__` too (the same gate hole in the one client the PR hadn't touched): new clients/launcher/tsconfig.test.json + a typecheck script folded into validate. Completes the "every client's __tests__ is typechecked" invariant. - Doc the new test-typecheck + config-file typecheck in AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 3 +- .../cli/__tests__/helpers/oauth-test-fakes.ts | 25 +++++++++++++---- clients/launcher/package.json | 3 +- clients/launcher/tsconfig.test.json | 14 ++++++++++ clients/tui/__tests__/App.test.tsx | 19 +++++++------ clients/tui/__tests__/HistoryTab.test.tsx | 28 ++++++++++++------- 6 files changed, 66 insertions(+), 26 deletions(-) create mode 100644 clients/launcher/tsconfig.test.json diff --git a/AGENTS.md b/AGENTS.md index 9b9ac1b77..6a30f3651 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,7 +230,8 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script (`tsc --noEmit -p tsconfig.json`) folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. + - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts` (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest). Each `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`; launcher's `typecheck` is just the test project, since its `build` already `tsc`s `src`). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`, so they're typechecked as well. Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts index 923ba34d2..f5198c413 100644 --- a/clients/cli/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -21,13 +21,26 @@ import type { CliOAuthClient } from "../../src/cliOAuth.js"; export function makeFakeCliOAuthClient( overrides: Partial = {}, ): CliOAuthClient { + // Each member is typed against the real CliOAuthClient signature (not a bare + // vi.fn()) so an override with a wrong implementation/return is rejected, the + // same way the tui App spies are typed. return { - connect: vi.fn().mockResolvedValue(undefined), - disconnect: vi.fn().mockResolvedValue(undefined), - authenticate: vi.fn().mockResolvedValue(undefined), - beginInteractiveAuthorization: vi.fn().mockResolvedValue(undefined), - completeOAuthFlow: vi.fn().mockResolvedValue(undefined), - checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi + .fn() + .mockResolvedValue(undefined), + authenticate: vi + .fn() + .mockResolvedValue(undefined), + beginInteractiveAuthorization: vi + .fn() + .mockResolvedValue(undefined), + completeOAuthFlow: vi + .fn() + .mockResolvedValue(undefined), + checkAuthChallengeSatisfied: vi + .fn() + .mockResolvedValue(false), ...overrides, }; } diff --git a/clients/launcher/package.json b/clients/launcher/package.json index f0602565a..91f675aad 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -15,11 +15,12 @@ "build": "tsc", "postbuild": "node scripts/make-executable.js", "lint": "eslint .", + "typecheck": "tsc --noEmit -p tsconfig.test.json", "format": "prettier --write 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:coverage": "vitest run --coverage", - "validate": "npm run format:check && npm run lint && npm run build && npm run test" + "validate": "npm run format:check && npm run lint && npm run build && npm run typecheck && npm run test" }, "dependencies": { "commander": "^13.1.0" diff --git a/clients/launcher/tsconfig.test.json b/clients/launcher/tsconfig.test.json new file mode 100644 index 000000000..ba8273764 --- /dev/null +++ b/clients/launcher/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + // Typecheck the __tests__ dir. The build config (`tsconfig.json`) emits with + // `rootDir: ./src`, so the tests can't join it — this is a `noEmit` sibling + // rooted at the package so it can reach both `__tests__` and the `src` the + // tests import. See #1791. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node"] + }, + "include": ["__tests__/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index b8c280655..da357dc43 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -48,15 +48,16 @@ const h = vi.hoisted(() => { const openUrl = vi.fn().mockResolvedValue(undefined); // Shared OAuth-related spies so a test can configure resolve/reject and // assert calls regardless of which per-server FakeClient instance App built. - // Each spy is typed against the real InspectorClient method signature so that - // `toHaveBeenCalledWith(...)` assertions against them stay argument-checked - // under the new gate — and the FakeClient wrappers below forward the same + // Each spy is typed against the real InspectorClient method signature so its + // implementation and `mockResolvedValue` / return payloads stay in sync with + // the client (this is what keeps a stale `{ kind: "satisfied" }` literal from + // narrowing `handleAuthChallenge`'s return). Note vitest does NOT type-check + // `toHaveBeenCalledWith(...)` arguments against the mock's signature, so those + // assertions stay runtime-only. The FakeClient wrappers below forward the same // `Parameters<…>` tuple, which spreads cleanly (a tuple, not `unknown[]`). - // `authenticate` keeps a string-returning stub (the real method returns a - // `URL`); it takes no arguments, so there is nothing to arg-check there. const clientSpies = { - authenticate: vi.fn<() => Promise>( - async () => "https://auth.example/start", + authenticate: vi.fn( + async () => new URL("https://auth.example/start"), ), clearOAuthTokens: vi.fn(), completeOAuthFlow: vi.fn( @@ -599,7 +600,9 @@ beforeEach(() => { h.clientInstances.length = 0; h.runner.override = null; h.clientSpies.authenticate.mockReset(); - h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start"); + h.clientSpies.authenticate.mockResolvedValue( + new URL("https://auth.example/start"), + ); h.clientSpies.clearOAuthTokens.mockReset(); h.clientSpies.completeOAuthFlow.mockReset(); h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); diff --git a/clients/tui/__tests__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx index 056268417..ea1fa11a7 100644 --- a/clients/tui/__tests__/HistoryTab.test.tsx +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -28,14 +28,13 @@ const PAGE_DOWN = `${ESC}[6~`; const ts = new Date("2024-01-01T12:34:56Z"); -// `message` is deliberately typed `unknown`: several entries below construct -// malformed wire messages (a response with neither result nor error; a -// notification with no method) to exercise HistoryTab's defensive rendering -// branches, which the strict JSONRPCMessage union can't represent. The return -// cast is the existing scaffolding cast that carries those shapes through. -const entry = ( - over: Omit, "message"> & { message?: unknown }, -): MessageEntry => +// `message` stays fully typed so a malformed field in a well-formed fixture is +// caught. The two intentionally-malformed messages below (a response with +// neither result nor error; a notification with no method) exercise HistoryTab's +// defensive branches and cannot be represented by the strict JSONRPCMessage +// union, so they carry a local `MALFORMED` cast at their own call sites rather +// than loosening the whole helper. +const entry = (over: Partial): MessageEntry => ({ id: "id", timestamp: ts, @@ -44,6 +43,15 @@ const entry = ( ...over, }) as unknown as MessageEntry; +// Cast for the two intentionally-malformed wire messages only. A single `as` +// won't bridge (the shapes don't overlap the union), so `as unknown as` is the +// only option; scoping it to a named helper keeps `entry()`'s `message` +// otherwise gate-checked. See the comment on `entry` above. +const MALFORMED = (m: { + jsonrpc: "2.0"; + id?: number; +}): MessageEntry["message"] => m as unknown as MessageEntry["message"]; + // One entry exercising each label / direction / detail branch. const reqWithResponse = entry({ id: "m0", @@ -70,7 +78,7 @@ const respError = entry({ const respPlain = entry({ id: "m4", direction: "response", - message: { jsonrpc: "2.0", id: 5 }, + message: MALFORMED({ jsonrpc: "2.0", id: 5 }), }); const notification = entry({ id: "m5", @@ -80,7 +88,7 @@ const notification = entry({ const unknownEntry = entry({ id: "m6", direction: "notification", - message: { jsonrpc: "2.0" }, + message: MALFORMED({ jsonrpc: "2.0" }), }); const allMessages: MessageEntry[] = [ From f5209477c30b07e010e94ea1c53360c1adab6894 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Jul 2026 23:55:17 -0400 Subject: [PATCH 04/39] =?UTF-8?q?test:=20address=20round-3=20nits=20?= =?UTF-8?q?=E2=80=94=20launcher=20config=20typecheck,=20callTool=20typed,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-3 @claude review of #1799 (all non-blocking): - launcher: add `vitest.config.ts` to tsconfig.test.json's include (it was in neither project — the build config's rootDir: ./src rejects it). Now typechecked. - App.test.tsx: type `callTool` against `InspectorClient["callTool"]` (+ the Parameters<…> wrapper) so its `mockResolvedValue` payloads are checked, and give `clearOAuthTokens` an `async () => {}` impl so the mock actually returns the `Promise` its type promises. - AGENTS.md: correct the per-client alias-set description (cli's is widest, tui's is core+react only, launcher's has no paths) and note launcher's config file lives in its test project, not the src project. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- clients/launcher/tsconfig.test.json | 4 +++- clients/tui/__tests__/App.test.tsx | 9 ++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a30f3651..dbf29d182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,7 +231,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts` (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest). Each `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`; launcher's `typecheck` is just the test project, since its `build` already `tsc`s `src`). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`, so they're typechecked as well. Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`; launcher's `typecheck` is just the test project, since its `build` already `tsc`s `src`). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/clients/launcher/tsconfig.test.json b/clients/launcher/tsconfig.test.json index ba8273764..19cbf0765 100644 --- a/clients/launcher/tsconfig.test.json +++ b/clients/launcher/tsconfig.test.json @@ -9,6 +9,8 @@ "rootDir": ".", "types": ["node"] }, - "include": ["__tests__/**/*"], + // `vitest.config.ts` lives here (not the src project) because the build + // config's `rootDir: ./src` rejects it — same reason as the tests. + "include": ["__tests__/**/*", "vitest.config.ts"], "exclude": ["node_modules", "build"] } diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index da357dc43..e29d040ad 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -59,14 +59,16 @@ const h = vi.hoisted(() => { authenticate: vi.fn( async () => new URL("https://auth.example/start"), ), - clearOAuthTokens: vi.fn(), + clearOAuthTokens: vi.fn( + async () => {}, + ), completeOAuthFlow: vi.fn( async () => {}, ), getOAuthState: vi.fn( async () => undefined, ), - callTool: vi.fn(), + callTool: vi.fn(), checkAuthChallengeSatisfied: vi.fn< InspectorClient["checkAuthChallengeSatisfied"] >(async () => false), @@ -145,7 +147,8 @@ const h = vi.hoisted(() => { ...a: Parameters ) => clientSpies.completeOAuthFlow(...a); getOAuthState = () => clientSpies.getOAuthState(); - callTool = (...a: unknown[]) => clientSpies.callTool(...a); + callTool = (...a: Parameters) => + clientSpies.callTool(...a); checkAuthChallengeSatisfied = ( ...a: Parameters ) => clientSpies.checkAuthChallengeSatisfied(...a); From 2d770a5b245fd5cc951b39f4133fb9cc74d9bcab Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:14:53 -0400 Subject: [PATCH 05/39] =?UTF-8?q?test:=20address=20round-4=20=E2=80=94=20d?= =?UTF-8?q?urable=20typecheck-coverage=20guard=20+=20launcher/doc=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-4 @claude review of #1799 (all non-blocking): - Add `verify:typecheck-coverage` (scripts/verify-typecheck-coverage.mjs), the typecheck-coverage analog of verify:format-coverage and the durable guard for the invariant this PR establishes: for each Node client it runs the tsconfig projects its `typecheck` names with `tsc --listFiles`, unions them, and fails on any tracked .ts/.tsx that lands in no project. This closes the class the review kept surfacing by hand (launcher/__tests__ in round 3, launcher/vitest.config.ts in round 4) — a new top-level file can no longer go untypechecked silently. Wired into `npm run ci` and the GitHub workflow. - launcher: reorder `validate` to run `typecheck` before `build` (matches tui and the documented order), and make `typecheck` self-contained — it now runs both `tsconfig.json` and `tsconfig.test.json`, so running it standalone means the same thing it does in cli/tui. - makeFakeCliOAuthClient: note on the comment that the typing covers the defaults, not `overrides` (a bare vi.fn() override is Mock<(...args)=>any>). - Docs: README table + AGENTS.md updated for the new guard and the corrected launcher typecheck/validate ordering. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .github/workflows/main.yml | 8 ++ AGENTS.md | 6 +- README.md | 3 +- .../cli/__tests__/helpers/oauth-test-fakes.ts | 9 +- clients/launcher/package.json | 4 +- package.json | 3 +- scripts/verify-typecheck-coverage.mjs | 134 ++++++++++++++++++ 7 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 scripts/verify-typecheck-coverage.mjs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9259214a0..9a0d4d756 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,6 +62,14 @@ jobs: # gate. It restores the mutated entry afterward (see the script). run: npm run verify:build-gate + - name: Verify every client source file gets a tsc pass (#1791) + # Durable guard for the "every tracked .ts/.tsx in cli/tui/launcher is in + # a tsconfig project" invariant #1791 established. A new top-level file + # (a config, a test helper) can silently fall outside every project and + # go untypechecked — the hole that surfaced twice during #1791's review. + # This is the typecheck-coverage analog of verify:format-coverage. + run: npm run verify:typecheck-coverage + # Playwright chromium is installed BEFORE the smokes because # `smoke:web:browser` (the headless-browser boot smoke, #1615) drives the # prod web bundle in chromium — restoring/installing it here lets that diff --git a/AGENTS.md b/AGENTS.md index dbf29d182..bba0956fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,14 +227,14 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. -- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). +- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `verify:typecheck-coverage` (the #1791 guard — asserts every tracked `.ts`/`.tsx` in cli/tui/launcher lands in a tsconfig project) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`; launcher's `typecheck` is just the test project, since its `build` already `tsc`s `src`). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, in `npm run ci` and the GitHub workflow) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFiles`, unions them, and fails on any tracked `.ts`/`.tsx` in cli/tui/launcher that lands in no project — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. -- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `verify:typecheck-coverage` (the #1791 typecheck-coverage guard), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a *class* (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first *call* into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A *synchronous* such throw fires `pageerror`; its *async* twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never *called* ships a harmless `{}` and is invisible here by design. Every *other* console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). diff --git a/README.md b/README.md index 652a9b41a..d0494fac3 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,8 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (cli/tui/launcher) it runs the tsconfig projects its `typecheck` names with `tsc --listFiles`, unions them, and **fails** listing any tracked `.ts`/`.tsx` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Part of `npm run ci`. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `verify:typecheck-coverage` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | 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. diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts index f5198c413..517a5c12a 100644 --- a/clients/cli/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -21,9 +21,12 @@ import type { CliOAuthClient } from "../../src/cliOAuth.js"; export function makeFakeCliOAuthClient( overrides: Partial = {}, ): CliOAuthClient { - // Each member is typed against the real CliOAuthClient signature (not a bare - // vi.fn()) so an override with a wrong implementation/return is rejected, the - // same way the tui App spies are typed. + // The defaults are typed against the real CliOAuthClient signatures (not bare + // vi.fn()s) so a default with a wrong implementation/return would be rejected, + // the same way the tui App spies are typed. Note this does NOT extend to + // `overrides`: a caller passing a bare `vi.fn()` gets `Mock<(...args) => any>`, + // assignable to any member, so an override's implementation is unchecked — the + // factory guarantees the object's shape, not each override's signature. return { connect: vi.fn().mockResolvedValue(undefined), disconnect: vi diff --git a/clients/launcher/package.json b/clients/launcher/package.json index 91f675aad..f39eecdd0 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -15,12 +15,12 @@ "build": "tsc", "postbuild": "node scripts/make-executable.js", "lint": "eslint .", - "typecheck": "tsc --noEmit -p tsconfig.test.json", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", "format": "prettier --write 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:coverage": "vitest run --coverage", - "validate": "npm run format:check && npm run lint && npm run build && npm run typecheck && npm run test" + "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { "commander": "^13.1.0" diff --git a/package.json b/package.json index dcd710d5f..f7ffdadca 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,10 @@ "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", - "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run smoke && npm run ci:storybook", + "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run verify:typecheck-coverage && npm run smoke && npm run ci:storybook", "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", + "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", "validate": "npm run verify:format-coverage && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", "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", diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs new file mode 100644 index 000000000..0f0c8a39d --- /dev/null +++ b/scripts/verify-typecheck-coverage.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// Durable guard for the "every tracked source file gets a `tsc` pass" invariant +// that #1791 established for the Node clients (cli, tui, launcher). A tsconfig +// project only typechecks the files its `include`/`files` name plus whatever +// those transitively import — so a new top-level `.ts` (a fresh config file, a +// new test helper) can silently fall outside every project and get no +// type-checking, exactly the hole that surfaced twice during #1791's review +// (`launcher/__tests__`, then `launcher/vitest.config.ts`). launcher is the most +// exposed: its build config's `rootDir: "./src"` actively rejects anything at +// the package root. +// +// This is the typecheck-coverage analog of `verify:format-coverage`: for each +// client it runs every project its `typecheck` script names with `tsc +// --listFiles` (the accurate measure — it includes import-reached files), unions +// the result, and asserts every tracked first-party `.ts`/`.tsx` under the +// client is in that union. Exits non-zero, listing the offenders, on any miss. +// +// Source of truth is the `typecheck` scripts themselves — this parser reads the +// `-p ` args out of them, so adding/removing a project is reflected +// here with no second list to keep in sync. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +// The clients whose `typecheck` runs explicit `-p ` passes (#1791). +// web is intentionally out: it typechecks via `tsc -b` over a project-reference +// graph (app/node/storybook/test) that already reaches its whole tree. +const CLIENTS = ["clients/cli", "clients/tui", "clients/launcher"]; + +// Ambient declaration files are excluded from the required set: an unreferenced +// `*.d.ts` shim (e.g. a `vitest.shims.d.ts`) is type-only and not a real gap. +const isRequiredSource = (rel) => + /\.(ts|tsx)$/.test(rel) && !rel.endsWith(".d.ts"); + +/** The tsconfig projects a client's `typecheck` script names via `-p `. */ +function typecheckProjects(clientDir) { + const pkg = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ); + const script = pkg.scripts?.typecheck ?? ""; + const projects = []; + for (const m of script.matchAll(/-p\s+(\S+)/g)) projects.push(m[1]); + return projects; +} + +/** + * Repo-relative POSIX paths of the files a project typechecks, restricted to + * those under `clientDir` (drops lib.d.ts, node_modules, and the aliased + * `core/` + `test-servers/` sources — those are gated by their own owners). + */ +function projectFiles(clientDir, project) { + const absClient = path.join(repoRoot, clientDir); + let stdout; + try { + stdout = execFileSync( + "npx", + ["tsc", "--noEmit", "-p", project, "--listFiles"], + { cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + } catch (err) { + // A type error makes tsc exit non-zero but it still prints the file list to + // stdout; keep it so a failing project doesn't mask a coverage gap. The + // type error itself is the `typecheck` script's job to fail on. + stdout = typeof err.stdout === "string" ? err.stdout : ""; + } + const covered = new Set(); + for (const line of stdout.split("\n")) { + const abs = line.trim(); + if (!abs) continue; + const rel = path.relative(repoRoot, abs); + if (rel.startsWith("..") || rel.includes("node_modules")) continue; + covered.add(rel.split(path.sep).join("/")); + } + return covered; +} + +/** Tracked first-party `.ts`/`.tsx` under a client (excludes build output). */ +function trackedSourceFiles(clientDir) { + const out = execFileSync("git", ["ls-files", "*.ts", "*.tsx"], { + cwd: path.join(repoRoot, clientDir), + encoding: "utf8", + }); + return out + .split("\n") + .filter(Boolean) + .map((f) => path.posix.join(clientDir, f)) + .filter(isRequiredSource) + .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); +} + +let totalChecked = 0; +const failures = []; +for (const clientDir of CLIENTS) { + const projects = typecheckProjects(clientDir); + if (projects.length === 0) { + failures.push( + `${clientDir}: its \`typecheck\` script names no \`-p \` — nothing is typechecked.`, + ); + continue; + } + const covered = new Set(); + for (const project of projects) + for (const f of projectFiles(clientDir, project)) covered.add(f); + + const tracked = trackedSourceFiles(clientDir); + totalChecked += tracked.length; + for (const f of tracked) + if (!covered.has(f)) failures.push(`${f} — in no tsconfig project`); +} + +if (failures.length > 0) { + console.error( + `verify:typecheck-coverage — ${failures.length} issue(s): tracked source files that get no \`tsc\` pass:\n`, + ); + for (const f of failures) console.error(" " + f); + console.error( + "\nAdd the file to a client's `tsconfig.json` / `tsconfig.test.json` `include`", + ); + console.error( + "(a top-level config file that the build config's `rootDir` rejects goes in the test project). See AGENTS.md.", + ); + process.exit(1); +} + +console.log( + `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files across ${CLIENTS.length} clients get a tsc pass.`, +); From 718c830f9f63a651c7a945453aaf16adef037898 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:31:54 -0400 Subject: [PATCH 06/39] =?UTF-8?q?test:=20address=20round-5=20=E2=80=94=20w?= =?UTF-8?q?ire-check=20the=20guard,=20--listFilesOnly,=20into=20validate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-5 @claude review of #1799: - Finding 1 (the pre-merge one): the guard now ports verify:format-coverage's reachability checks — it asserts each client's `typecheck` is reachable from its own `validate` and that the root chain runs each client's `validate`, so it can't stay green while measuring a typecheck nothing invokes. Verified: stripping `npm run typecheck` from launcher's validate now fails the guard. - Nit 3: switch `tsc --listFiles` → `tsc --listFilesOnly` (identical file list, ~4× faster: whole guard 23s → ~4s). Now cheap enough for the inner loop, so it moves into `validate` (right after verify:format-coverage) instead of a standalone ci step — matching its sibling. Removed the separate ci-chain entry and GitHub workflow step (validate covers it). - Nit 4: `npx --no-install tsc` so a missing local tsc fails fast instead of reaching for the registry (matches verify-build-gate.mjs). - Nit 5: corrected projectFiles' JSDoc to describe the filter that's actually there (drops repo-external + node_modules paths; core/ + test-servers/ stay but are harmless). - Finding 2: fixed the 2-space misindent on the new root package.json line (prettier --write). Docs (README/AGENTS) updated for the validate placement. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .github/workflows/main.yml | 8 -- AGENTS.md | 8 +- README.md | 4 +- package.json | 6 +- scripts/verify-typecheck-coverage.mjs | 116 ++++++++++++++++++++++---- 5 files changed, 108 insertions(+), 34 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9a0d4d756..9259214a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,14 +62,6 @@ jobs: # gate. It restores the mutated entry afterward (see the script). run: npm run verify:build-gate - - name: Verify every client source file gets a tsc pass (#1791) - # Durable guard for the "every tracked .ts/.tsx in cli/tui/launcher is in - # a tsconfig project" invariant #1791 established. A new top-level file - # (a config, a test helper) can silently fall outside every project and - # go untypechecked — the hole that surfaced twice during #1791's review. - # This is the typecheck-coverage analog of verify:format-coverage. - run: npm run verify:typecheck-coverage - # Playwright chromium is installed BEFORE the smokes because # `smoke:web:browser` (the headless-browser boot smoke, #1615) drives the # prod web bundle in chromium — restoring/installing it here lets that diff --git a/AGENTS.md b/AGENTS.md index bba0956fa..81f376241 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,14 +227,14 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. -- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `verify:typecheck-coverage` (the #1791 guard — asserts every tracked `.ts`/`.tsx` in cli/tui/launcher lands in a tsconfig project) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx` in cli/tui/launcher lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, in `npm run ci` and the GitHub workflow) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFiles`, unions them, and fails on any tracked `.ts`/`.tsx` in cli/tui/launcher that lands in no project — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx` in cli/tui/launcher that lands in no project — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. -- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `verify:typecheck-coverage` (the #1791 typecheck-coverage guard), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a *class* (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first *call* into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A *synchronous* such throw fires `pageerror`; its *async* twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never *called* ships a harmless `{}` and is invisible here by design. Every *other* console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). diff --git a/README.md b/README.md index d0494fac3..fc941b685 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,8 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (cli/tui/launcher) it runs the tsconfig projects its `typecheck` names with `tsc --listFiles`, unions them, and **fails** listing any tracked `.ts`/`.tsx` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Part of `npm run ci`. | -| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `verify:typecheck-coverage` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (cli/tui/launcher) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Also asserts the gate is wired (each `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | 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. diff --git a/package.json b/package.json index f7ffdadca..ea374d780 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,11 @@ "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", - "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run verify:typecheck-coverage && npm run smoke && npm run ci:storybook", + "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run smoke && npm run ci:storybook", "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", - "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", - "validate": "npm run verify:format-coverage && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", + "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", "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}\"", diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 0f0c8a39d..99617ee2e 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -11,9 +11,16 @@ // // This is the typecheck-coverage analog of `verify:format-coverage`: for each // client it runs every project its `typecheck` script names with `tsc -// --listFiles` (the accurate measure — it includes import-reached files), unions -// the result, and asserts every tracked first-party `.ts`/`.tsx` under the -// client is in that union. Exits non-zero, listing the offenders, on any miss. +// --listFilesOnly` (the accurate measure — it includes import-reached files), +// unions the result, and asserts every tracked first-party `.ts`/`.tsx` under +// the client is in that union. Exits non-zero, listing the offenders, on any +// miss. +// +// Like its sibling it also asserts the gate is actually WIRED: each client's +// `typecheck` must be reachable from that client's `validate`, and the root +// `validate` chain must invoke each client's `validate` — otherwise the guard +// could measure a `typecheck` script that CI no longer runs and stay green while +// nothing is typechecked ("gate silently stops gating"). // // Source of truth is the `typecheck` scripts themselves — this parser reads the // `-p ` args out of them, so adding/removing a project is reflected @@ -31,7 +38,8 @@ const repoRoot = path.resolve( // The clients whose `typecheck` runs explicit `-p ` passes (#1791). // web is intentionally out: it typechecks via `tsc -b` over a project-reference -// graph (app/node/storybook/test) that already reaches its whole tree. +// graph (app/node/storybook/test) that already reaches its whole tree. Kept an +// explicit list, mirroring `verify-format-coverage.mjs`'s `MANIFESTS`. const CLIENTS = ["clients/cli", "clients/tui", "clients/launcher"]; // Ambient declaration files are excluded from the required set: an unreferenced @@ -39,21 +47,41 @@ const CLIENTS = ["clients/cli", "clients/tui", "clients/launcher"]; const isRequiredSource = (rel) => /\.(ts|tsx)$/.test(rel) && !rel.endsWith(".d.ts"); +/** + * Names of scripts transitively reachable from `entry` by following `npm run + * ` references within a manifest. Used to assert a client's `typecheck` + * is actually invoked by its `validate` — a `typecheck` script nothing runs + * gates nothing, and measuring its projects would let the gate be silently + * unwired. (Same technique as `verify-format-coverage.mjs`.) + */ +function reachableScripts(scripts, entry = "validate") { + const reached = new Set(); + const queue = [entry]; + const runRef = /npm run ([\w:-]+)/g; + while (queue.length > 0) { + const name = queue.shift(); + if (reached.has(name)) continue; + reached.add(name); + const cmd = scripts?.[name]; + if (typeof cmd !== "string") continue; + for (const m of cmd.matchAll(runRef)) queue.push(m[1]); + } + return reached; +} + /** The tsconfig projects a client's `typecheck` script names via `-p `. */ -function typecheckProjects(clientDir) { - const pkg = JSON.parse( - readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), - ); - const script = pkg.scripts?.typecheck ?? ""; +function typecheckProjects(scripts) { + const script = scripts?.typecheck ?? ""; const projects = []; for (const m of script.matchAll(/-p\s+(\S+)/g)) projects.push(m[1]); return projects; } /** - * Repo-relative POSIX paths of the files a project typechecks, restricted to - * those under `clientDir` (drops lib.d.ts, node_modules, and the aliased - * `core/` + `test-servers/` sources — those are gated by their own owners). + * Repo-relative POSIX paths of the files a project typechecks. Absolute paths + * outside the repo root (lib.d.ts) and anything under `node_modules` are + * dropped; the aliased `core/` + `test-servers/` sources stay in the set but + * are harmless — the set is only ever queried with client-relative paths. */ function projectFiles(clientDir, project) { const absClient = path.join(repoRoot, clientDir); @@ -61,13 +89,14 @@ function projectFiles(clientDir, project) { try { stdout = execFileSync( "npx", - ["tsc", "--noEmit", "-p", project, "--listFiles"], + ["--no-install", "tsc", "-p", project, "--listFilesOnly"], { cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); } catch (err) { - // A type error makes tsc exit non-zero but it still prints the file list to - // stdout; keep it so a failing project doesn't mask a coverage gap. The - // type error itself is the `typecheck` script's job to fail on. + // `--listFilesOnly` doesn't type-check, but a config error (an unreadable or + // malformed tsconfig) still exits non-zero while printing the resolved file + // list; keep stdout so a broken config doesn't mask a coverage gap. The + // config error itself is the `typecheck` script's job to fail on. stdout = typeof err.stdout === "string" ? err.stdout : ""; } const covered = new Set(); @@ -95,10 +124,63 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } +/** + * Assert the gate is wired: the root `validate` chain invokes each client's + * `validate`, and each client's `validate` reaches its `typecheck`. Returns a + * list of human-readable wiring failures. Without this the guard could run a + * `typecheck` script that CI never invokes and still report OK. + */ +function wiringFailures() { + const failures = []; + const rootPkg = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ); + const rootReachedCommands = [...reachableScripts(rootPkg.scripts)] + .map((n) => rootPkg.scripts?.[n]) + .filter((c) => typeof c === "string"); + + for (const clientDir of CLIENTS) { + if ( + !rootReachedCommands.some( + (c) => c.includes(`cd ${clientDir}`) && /npm run validate/.test(c), + ) + ) { + failures.push( + `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` — its typecheck isn't invoked by CI.`, + ); + continue; + } + const scripts = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ).scripts; + if (!reachableScripts(scripts, "validate").has("typecheck")) { + failures.push( + `${clientDir}: \`typecheck\` is not reachable from its \`validate\` — the typecheck it measures gates nothing.`, + ); + } + } + return failures; +} + +const wiring = wiringFailures(); +if (wiring.length > 0) { + console.error( + `verify:typecheck-coverage — ${wiring.length} wiring issue(s): a typecheck gate is not actually run:\n`, + ); + for (const f of wiring) console.error(" " + f); + console.error( + "\nRestore the `typecheck` link in the client's `validate` (and the `validate:` link in the root `validate`).", + ); + process.exit(1); +} + let totalChecked = 0; const failures = []; for (const clientDir of CLIENTS) { - const projects = typecheckProjects(clientDir); + const scripts = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ).scripts; + const projects = typecheckProjects(scripts); if (projects.length === 0) { failures.push( `${clientDir}: its \`typecheck\` script names no \`-p \` — nothing is typechecked.`, From 464ae40549fbc1307c13e9c49380ae3b72bb6969 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 00:50:04 -0400 Subject: [PATCH 07/39] =?UTF-8?q?chore(scripts):=20address=20round-6=20gua?= =?UTF-8?q?rd=20nits=20=E2=80=94=20dedupe=20wiring,=20robust=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-6 @claude review of #1799 (all guard-ergonomics nits): - Nit 2: extract the shared reachability logic (`reachableScripts`, `rootRunsClientValidate`) into scripts/lib/npm-scripts.mjs and consume it from BOTH verify-format-coverage.mjs and verify-typecheck-coverage.mjs, so the "is this gate actually run?" wiring can't drift between them (same rationale as scripts/lib/prod-web-server.mjs). - Nit 1: typecheckProjects now harvests `-p`/`--project` from every script reachable from `typecheck` (not the single string), so a delegating `typecheck` (`npm run typecheck:src && …`) is handled instead of failing with a false "names no -p project" diagnosis. Also accepts `--project`. - Nit 3: on a tsc config error, echo the `error TS…` diagnostic (scanning both streams — tsc prints config errors to stdout) so a broken tsconfig is self-explaining rather than surfacing only as a misleading "file in no project" report. - Nit 4: refreshed the CI workflow's stale "Validate" step name/comment (it now runs the coverage guards + validate:core + per-client typecheck). - Nit 5: reworded the `.d.ts` carve-out comment to read as pre-emptive (there are none under cli/tui/launcher today). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .github/workflows/main.yml | 6 ++- scripts/lib/npm-scripts.mjs | 46 ++++++++++++++++++ scripts/verify-format-coverage.mjs | 35 ++------------ scripts/verify-typecheck-coverage.mjs | 69 +++++++++++++-------------- 4 files changed, 89 insertions(+), 67 deletions(-) create mode 100644 scripts/lib/npm-scripts.mjs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9259214a0..5fdc748db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,8 +28,10 @@ jobs: # clients/launcher, so this single step sets up every client. run: npm install - - name: Validate (format, lint, build, fast tests) - # Each client self-validates: format:check + lint + build + test (no + - name: Validate (coverage guards, format, lint, typecheck, build, fast tests) + # Runs the two coverage guards first (verify:format-coverage + + # verify:typecheck-coverage), then validate:core, then each client's + # self-validation: format:check + lint + typecheck + build + test (no # coverage instrumentation — fast). This also builds every client bundle # (web dist, cli/tui/launcher) that the smokes below need. The heavier # per-file coverage gate runs in its own step below. Unit tests run in diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs new file mode 100644 index 000000000..c63d64472 --- /dev/null +++ b/scripts/lib/npm-scripts.mjs @@ -0,0 +1,46 @@ +// Shared npm-script reachability helpers used by both coverage guards +// (`verify-format-coverage.mjs`, `verify-typecheck-coverage.mjs`). Extracted so +// the wiring logic both depend on — "is this gate actually run?" — can't drift +// between them (the same rationale `scripts/lib/prod-web-server.mjs` was +// extracted under). + +/** + * Names of scripts transitively reachable from `entry` by following `npm run + * ` references within a single manifest's `scripts`. A gate harvested from + * a script that nothing reachable from `entry` invokes gates nothing, so callers + * restrict to this set to assert "CI actually runs this", not merely "the script + * exists". + */ +export function reachableScripts(scripts, entry = "validate") { + const reached = new Set(); + const queue = [entry]; + const runRef = /npm run ([\w:-]+)/g; + while (queue.length > 0) { + const name = queue.shift(); + if (reached.has(name)) continue; + reached.add(name); + const cmd = scripts?.[name]; + if (typeof cmd !== "string") continue; + for (const m of cmd.matchAll(runRef)) queue.push(m[1]); + } + return reached; +} + +/** The command strings of every script reachable from the root `validate`. */ +export function rootReachedCommands(rootScripts) { + return [...reachableScripts(rootScripts)] + .map((n) => rootScripts?.[n]) + .filter((c) => typeof c === "string"); +} + +/** + * Whether the root `validate` chain invokes `cd && npm run validate`. + * Without this a per-client gate would still be harvested from that client's own + * `validate` and count as coverage even after the root chain stopped running it + * — the "gate silently stops gating" failure, one level up. + */ +export function rootRunsClientValidate(rootScripts, clientDir) { + return rootReachedCommands(rootScripts).some( + (c) => c.includes(`cd ${clientDir}`) && /npm run validate/.test(c), + ); +} diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 13fca8299..69a4a2f3a 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -17,6 +17,10 @@ import { readFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + reachableScripts, + rootRunsClientValidate, +} from "./lib/npm-scripts.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -60,28 +64,6 @@ function tokenize(command) { return tokens; } -/** - * Names of scripts transitively reachable from `entry` by following `npm run - * ` references within a manifest. Used so we only trust a `format:check` - * glob that CI actually runs — a `prettier --check` script that nothing invokes - * from `validate` doesn't gate anything, and counting its globs would let the - * gate be silently unwired (the file still "matches a glob" that never runs). - */ -function reachableScripts(scripts, entry = "validate") { - const reached = new Set(); - const queue = [entry]; - const runRef = /npm run ([\w:-]+)/g; - while (queue.length > 0) { - const name = queue.shift(); - if (reached.has(name)) continue; - reached.add(name); - const cmd = scripts?.[name]; - if (typeof cmd !== "string") continue; - for (const m of cmd.matchAll(runRef)) queue.push(m[1]); - } - return reached; -} - /** * Extract the path/glob args from every `prettier --check …` in a manifest's * scripts that is reachable from `validate`. Restricting to reachable scripts is @@ -159,15 +141,8 @@ function clientsUnreachedFromRoot() { const rootPkg = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); - const reachedNames = reachableScripts(rootPkg.scripts); - const reachedCommands = [...reachedNames] - .map((n) => rootPkg.scripts?.[n]) - .filter((c) => typeof c === "string"); return MANIFESTS.filter((dir) => dir !== ".").filter( - (dir) => - !reachedCommands.some( - (c) => c.includes(`cd ${dir}`) && /npm run validate/.test(c), - ), + (dir) => !rootRunsClientValidate(rootPkg.scripts, dir), ); } diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 99617ee2e..0df67037d 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -23,13 +23,17 @@ // nothing is typechecked ("gate silently stops gating"). // // Source of truth is the `typecheck` scripts themselves — this parser reads the -// `-p ` args out of them, so adding/removing a project is reflected -// here with no second list to keep in sync. +// `-p`/`--project` args out of every script reachable from `typecheck`, so +// adding/removing a project is reflected here with no second list to keep in sync. import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + reachableScripts, + rootRunsClientValidate, +} from "./lib/npm-scripts.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -43,37 +47,26 @@ const repoRoot = path.resolve( const CLIENTS = ["clients/cli", "clients/tui", "clients/launcher"]; // Ambient declaration files are excluded from the required set: an unreferenced -// `*.d.ts` shim (e.g. a `vitest.shims.d.ts`) is type-only and not a real gap. +// `*.d.ts` shim (e.g. web's `vitest.shims.d.ts`) is type-only and not a real +// gap. There are none under cli/tui/launcher today; this pre-empts one. const isRequiredSource = (rel) => /\.(ts|tsx)$/.test(rel) && !rel.endsWith(".d.ts"); /** - * Names of scripts transitively reachable from `entry` by following `npm run - * ` references within a manifest. Used to assert a client's `typecheck` - * is actually invoked by its `validate` — a `typecheck` script nothing runs - * gates nothing, and measuring its projects would let the gate be silently - * unwired. (Same technique as `verify-format-coverage.mjs`.) + * The tsconfig projects a client's `typecheck` names via `-p`/`--project`, + * harvested from **every** script reachable from `typecheck` (not just the one + * string) so a delegating `typecheck` (`npm run typecheck:src && …`) still + * counts — matching how `verify-format-coverage.mjs` harvests globs across + * reachable scripts. */ -function reachableScripts(scripts, entry = "validate") { - const reached = new Set(); - const queue = [entry]; - const runRef = /npm run ([\w:-]+)/g; - while (queue.length > 0) { - const name = queue.shift(); - if (reached.has(name)) continue; - reached.add(name); +function typecheckProjects(scripts) { + const projects = []; + for (const name of reachableScripts(scripts, "typecheck")) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; - for (const m of cmd.matchAll(runRef)) queue.push(m[1]); + for (const m of cmd.matchAll(/(?:-p|--project)\s+(\S+)/g)) + projects.push(m[1]); } - return reached; -} - -/** The tsconfig projects a client's `typecheck` script names via `-p `. */ -function typecheckProjects(scripts) { - const script = scripts?.typecheck ?? ""; - const projects = []; - for (const m of script.matchAll(/-p\s+(\S+)/g)) projects.push(m[1]); return projects; } @@ -95,9 +88,22 @@ function projectFiles(clientDir, project) { } catch (err) { // `--listFilesOnly` doesn't type-check, but a config error (an unreadable or // malformed tsconfig) still exits non-zero while printing the resolved file - // list; keep stdout so a broken config doesn't mask a coverage gap. The - // config error itself is the `typecheck` script's job to fail on. + // list; keep stdout so a broken config doesn't mask a coverage gap. Echo the + // diagnostic — since this guard runs before any client's own `typecheck`, + // it's the first place a bad `-p` config surfaces, and without the reason + // the resulting "file in no project" report is misleading. tsc prints config + // errors (`error TS…`) to stdout, so scan both streams for them. stdout = typeof err.stdout === "string" ? err.stdout : ""; + const streams = + stdout + "\n" + (typeof err.stderr === "string" ? err.stderr : ""); + const diagnostic = streams + .split("\n") + .filter((l) => /error TS\d+/.test(l)) + .join("\n") + .trim(); + console.warn( + `verify:typecheck-coverage — \`tsc -p ${project}\` (in ${clientDir}) exited non-zero:\n${diagnostic || "(no diagnostic captured)"}\n`, + ); } const covered = new Set(); for (const line of stdout.split("\n")) { @@ -135,16 +141,9 @@ function wiringFailures() { const rootPkg = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); - const rootReachedCommands = [...reachableScripts(rootPkg.scripts)] - .map((n) => rootPkg.scripts?.[n]) - .filter((c) => typeof c === "string"); for (const clientDir of CLIENTS) { - if ( - !rootReachedCommands.some( - (c) => c.includes(`cd ${clientDir}`) && /npm run validate/.test(c), - ) - ) { + if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { failures.push( `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` — its typecheck isn't invoked by CI.`, ); From 73bd04396571c4f4ab17ecd6b2fea6a979e86d70 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:05:45 -0400 Subject: [PATCH 08/39] =?UTF-8?q?chore(scripts):=20round-7=20=E2=80=94=20g?= =?UTF-8?q?uard=20.mts/.cts=20too;=20drop=20dead=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-7 @claude review of #1799: - Finding 1: verify-typecheck-coverage now requires a tsc pass for tracked `.mts`/`.cts` (not just `.ts`/`.tsx`), matching verify-format-coverage's extension set — `.mts` is already the shared-config idiom here, so a client `vitest.config.ts` → `.mts` rename can no longer drop out of both the tsconfig include and the guard's required set. No-op on today's tree (no client `.mts`/`.cts`); confirmed a tracked `.mts` with a type error is now caught. Ambient `.d.{ts,mts,cts}` stay excluded. - Nit 2: drop the unused `export` on `rootReachedCommands` (internal to the lib; nothing imports it, and scripts/ isn't eslint-gated to catch dead exports). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/lib/npm-scripts.mjs | 2 +- scripts/verify-typecheck-coverage.mjs | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index c63d64472..3246914df 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -27,7 +27,7 @@ export function reachableScripts(scripts, entry = "validate") { } /** The command strings of every script reachable from the root `validate`. */ -export function rootReachedCommands(rootScripts) { +function rootReachedCommands(rootScripts) { return [...reachableScripts(rootScripts)] .map((n) => rootScripts?.[n]) .filter((c) => typeof c === "string"); diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 0df67037d..4222d0041 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -46,11 +46,14 @@ const repoRoot = path.resolve( // explicit list, mirroring `verify-format-coverage.mjs`'s `MANIFESTS`. const CLIENTS = ["clients/cli", "clients/tui", "clients/launcher"]; -// Ambient declaration files are excluded from the required set: an unreferenced -// `*.d.ts` shim (e.g. web's `vitest.shims.d.ts`) is type-only and not a real -// gap. There are none under cli/tui/launcher today; this pre-empts one. +// TypeScript source extensions this guard requires a tsc pass for. Matches its +// sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom +// for shared config here, e.g. `vitest.shared.mts`, so a client `.mts`/`.cts` +// must be gated too). Ambient declaration files are excluded: an unreferenced +// `*.d.{ts,mts,cts}` shim (e.g. web's `vitest.shims.d.ts`) is type-only and not +// a real gap. There are no client `.mts`/`.cts` today; this pre-empts one. const isRequiredSource = (rel) => - /\.(ts|tsx)$/.test(rel) && !rel.endsWith(".d.ts"); + /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); /** * The tsconfig projects a client's `typecheck` names via `-p`/`--project`, @@ -118,10 +121,11 @@ function projectFiles(clientDir, project) { /** Tracked first-party `.ts`/`.tsx` under a client (excludes build output). */ function trackedSourceFiles(clientDir) { - const out = execFileSync("git", ["ls-files", "*.ts", "*.tsx"], { - cwd: path.join(repoRoot, clientDir), - encoding: "utf8", - }); + const out = execFileSync( + "git", + ["ls-files", "*.ts", "*.tsx", "*.mts", "*.cts"], + { cwd: path.join(repoRoot, clientDir), encoding: "utf8" }, + ); return out .split("\n") .filter(Boolean) From 1bb5524e7110b787823043de507436d53ee169b1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:20:24 -0400 Subject: [PATCH 09/39] =?UTF-8?q?test:=20round-8=20=E2=80=94=20single=20ca?= =?UTF-8?q?sts=20in=20HistoryTab,=20uniform=20wrappers,=20doc=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-8 @claude review of #1799: - Finding 1: reduce the `MALFORMED` helper and `entry()`'s return cast from `as unknown as` to a single `as` (both compile clean — the malformed shapes are comparable to the JSONRPCMessage union), and delete the false "only option" sentence. Fixes the AGENTS.md avoid-double-casts violation. - Nit 3: write all seven FakeClient OAuth wrappers as `(...a: Parameters)` (the three zero-arg ones were hand-written `()` forwarders), so the block comment's "forward the same Parameters<…> tuple" is literally true and a future added parameter can't be silently dropped. - Nit 2: update the five places that still described the guard as `.ts`/`.tsx` only to `.ts`/`.tsx`/`.mts`/`.cts` (script header + trackedSourceFiles JSDoc, AGENTS.md ×2, README.md). Nits 4 (js config files out of the typecheck guard by construction) and the footnoted callTool/ancestor-tsc items left as agreed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 4 ++-- README.md | 2 +- clients/tui/__tests__/App.test.tsx | 10 +++++++--- clients/tui/__tests__/HistoryTab.test.tsx | 11 +++++------ scripts/verify-typecheck-coverage.mjs | 8 ++++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81f376241..d1c2a35e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,10 +228,10 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx` in cli/tui/launcher lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in cli/tui/launcher lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx` in cli/tui/launcher that lands in no project — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` in cli/tui/launcher that lands in no project — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/README.md b/README.md index fc941b685..f52a90213 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (cli/tui/launcher) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Also asserts the gate is wired (each `typecheck` 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 (cli/tui/launcher) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Also asserts the gate is wired (each `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index e29d040ad..d70b9c274 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -141,12 +141,16 @@ const h = vi.hoisted(() => { | "sse" | "streamable-http", ); - authenticate = () => clientSpies.authenticate(); - clearOAuthTokens = () => clientSpies.clearOAuthTokens(); + authenticate = (...a: Parameters) => + clientSpies.authenticate(...a); + clearOAuthTokens = ( + ...a: Parameters + ) => clientSpies.clearOAuthTokens(...a); completeOAuthFlow = ( ...a: Parameters ) => clientSpies.completeOAuthFlow(...a); - getOAuthState = () => clientSpies.getOAuthState(); + getOAuthState = (...a: Parameters) => + clientSpies.getOAuthState(...a); callTool = (...a: Parameters) => clientSpies.callTool(...a); checkAuthChallengeSatisfied = ( diff --git a/clients/tui/__tests__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx index ea1fa11a7..968570219 100644 --- a/clients/tui/__tests__/HistoryTab.test.tsx +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -41,16 +41,15 @@ const entry = (over: Partial): MessageEntry => direction: "request", message: { jsonrpc: "2.0", id: 1, method: "ping" }, ...over, - }) as unknown as MessageEntry; + }) as MessageEntry; -// Cast for the two intentionally-malformed wire messages only. A single `as` -// won't bridge (the shapes don't overlap the union), so `as unknown as` is the -// only option; scoping it to a named helper keeps `entry()`'s `message` -// otherwise gate-checked. See the comment on `entry` above. +// Cast for the two intentionally-malformed wire messages only, scoped to a named +// helper so `entry()`'s `message` stays gate-checked for every well-formed +// fixture. See the comment on `entry` above. const MALFORMED = (m: { jsonrpc: "2.0"; id?: number; -}): MessageEntry["message"] => m as unknown as MessageEntry["message"]; +}): MessageEntry["message"] => m as MessageEntry["message"]; // One entry exercising each label / direction / detail branch. const reqWithResponse = entry({ diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 4222d0041..30b684532 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -12,9 +12,9 @@ // This is the typecheck-coverage analog of `verify:format-coverage`: for each // client it runs every project its `typecheck` script names with `tsc // --listFilesOnly` (the accurate measure — it includes import-reached files), -// unions the result, and asserts every tracked first-party `.ts`/`.tsx` under -// the client is in that union. Exits non-zero, listing the offenders, on any -// miss. +// unions the result, and asserts every tracked first-party `.ts`/`.tsx`/`.mts`/ +// `.cts` under the client is in that union. Exits non-zero, listing the +// offenders, on any miss. // // Like its sibling it also asserts the gate is actually WIRED: each client's // `typecheck` must be reachable from that client's `validate`, and the root @@ -119,7 +119,7 @@ function projectFiles(clientDir, project) { return covered; } -/** Tracked first-party `.ts`/`.tsx` under a client (excludes build output). */ +/** Tracked first-party TS (`.ts`/`.tsx`/`.mts`/`.cts`) under a client (excludes build output). */ function trackedSourceFiles(clientDir) { const out = execFileSync( "git", From d141cd2030958bc6e0aed733e1797663fa1b7bde Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:35:32 -0400 Subject: [PATCH 10/39] =?UTF-8?q?test:=20round-9=20=E2=80=94=20drop=20entr?= =?UTF-8?q?y()'s=20harmful=20cast;=20guards=20vouch=20for=20each=20other?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-9 @claude review of #1799: - Finding 1: remove `entry()`'s `as MessageEntry` return cast in HistoryTab.test.tsx. It was unnecessary (the spread of Partial over complete defaults is directly assignable) and harmful — it silenced type errors in the helper's OWN default fields (a `direction`/`method` typo now surfaces TS2820/TS2353). MALFORMED's single cast stays (load-bearing). This leaves MALFORMED as the file's only cast, matching what the comments describe. - Nit 2: add `rootReachesScript` to scripts/lib/npm-scripts.mjs and have each coverage guard assert its SIBLING is still wired into the root `validate`. A guard can't detect being unrun itself, but the two now vouch for each other, so dropping either from `validate` is caught by the other (only deleting both slips through). Verified: removing verify:typecheck-coverage from validate now fails verify:format-coverage, and vice-versa. Nits 3 (git-index visibility) and 4 (pre-existing as-never/as-unknown casts in tests — out of scope, none added by this PR) left as agreed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/tui/__tests__/HistoryTab.test.tsx | 15 +++++++-------- scripts/lib/npm-scripts.mjs | 10 ++++++++++ scripts/verify-format-coverage.mjs | 14 ++++++++++++++ scripts/verify-typecheck-coverage.mjs | 10 ++++++++++ 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/clients/tui/__tests__/HistoryTab.test.tsx b/clients/tui/__tests__/HistoryTab.test.tsx index 968570219..4710402d5 100644 --- a/clients/tui/__tests__/HistoryTab.test.tsx +++ b/clients/tui/__tests__/HistoryTab.test.tsx @@ -34,14 +34,13 @@ const ts = new Date("2024-01-01T12:34:56Z"); // defensive branches and cannot be represented by the strict JSONRPCMessage // union, so they carry a local `MALFORMED` cast at their own call sites rather // than loosening the whole helper. -const entry = (over: Partial): MessageEntry => - ({ - id: "id", - timestamp: ts, - direction: "request", - message: { jsonrpc: "2.0", id: 1, method: "ping" }, - ...over, - }) as MessageEntry; +const entry = (over: Partial): MessageEntry => ({ + id: "id", + timestamp: ts, + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: "ping" }, + ...over, +}); // Cast for the two intentionally-malformed wire messages only, scoped to a named // helper so `entry()`'s `message` stays gate-checked for every well-formed diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index 3246914df..d2ab206e2 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -44,3 +44,13 @@ export function rootRunsClientValidate(rootScripts, clientDir) { (c) => c.includes(`cd ${clientDir}`) && /npm run validate/.test(c), ); } + +/** + * Whether `scriptName` is reachable from the root `validate`. A guard can't + * assert it is *itself* run (an unrun guard runs no check), but the two coverage + * guards can each assert the *other* is still wired — so dropping either from + * `validate` is caught by its sibling. Only deleting both slips through. + */ +export function rootReachesScript(rootScripts, scriptName) { + return reachableScripts(rootScripts, "validate").has(scriptName); +} diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 69a4a2f3a..5ac4d44b6 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -19,6 +19,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { reachableScripts, + rootReachesScript, rootRunsClientValidate, } from "./lib/npm-scripts.mjs"; @@ -175,6 +176,19 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } +// Vouch for the sibling guard: a guard can't detect being unrun itself, but the +// two coverage guards can each assert the other is still wired into `validate`, +// so dropping either is caught here. Only deleting both slips through. +const rootScripts = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), +).scripts; +if (!rootReachesScript(rootScripts, "verify:typecheck-coverage")) { + console.error( + "verify:format-coverage — the root `validate` no longer runs `verify:typecheck-coverage` (its sibling guard). Restore it.", + ); + process.exit(1); +} + const unreachedClients = clientsUnreachedFromRoot(); if (unreachedClients.length > 0) { console.error( diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 30b684532..53d39bc5f 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -32,6 +32,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { reachableScripts, + rootReachesScript, rootRunsClientValidate, } from "./lib/npm-scripts.mjs"; @@ -146,6 +147,15 @@ function wiringFailures() { readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); + // Assert the sibling guard is still wired — a guard can't detect being unrun + // itself, but each can vouch for the other, so dropping either from `validate` + // is caught here (only deleting both slips through). + if (!rootReachesScript(rootPkg.scripts, "verify:format-coverage")) { + failures.push( + "the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.", + ); + } + for (const clientDir of CLIENTS) { if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { failures.push( From 25ff5f27ab7e454b2c5cada7fa1eb357e7dc13b1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 01:52:48 -0400 Subject: [PATCH 11/39] =?UTF-8?q?test:=20round-10=20=E2=80=94=20reject=20n?= =?UTF-8?q?on-checking=20typecheck=20passes;=20pre/post=20hooks;=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-10 @claude review of #1799: - Finding 1: verify-typecheck-coverage now rejects a `-p ` whose own command carries `--noCheck` or `--listFilesOnly` — such a pass lists files without type-checking them, which previously satisfied both the wiring and file-coverage halves while checking nothing. (Splits each script on `&&`/`||`/`;` so a flag on one command can't taint another.) Verified a `typecheck` using `tsc … --noCheck` now fails with a clear message; `--noCheck` is a live idiom here (cli's test-servers:build), so this is a real copy-paste hazard. - Nit 2: reachableScripts (shared lib) now also follows npm's implicit `pre`/`post` lifecycle hooks, so moving a gate into e.g. `prevalidate` is recognized instead of producing a false "not reachable" failure (cli already uses a `pretest` hook). Verified. - Nit 3: AGENTS.md "Test placement" now states the Node clients (cli/tui/ launcher) keep all tests in `__tests__/`, enforced by verify:typecheck-coverage (a co-located src test lands in no tsconfig project). - Nit 4: noted on the factory comments that `Partial<…>` overrides admit an explicit `undefined` for a required field (exactOptionalPropertyTypes off). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 1 + .../cli/__tests__/helpers/oauth-test-fakes.ts | 8 +++-- .../tui/__tests__/helpers/oauth-test-fakes.ts | 4 ++- scripts/lib/npm-scripts.mjs | 13 +++++--- scripts/verify-typecheck-coverage.mjs | 30 ++++++++++++++----- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1c2a35e9..b95a8b4d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,6 +217,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* - **Side-by-side (`.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside. + - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes only `__tests__/**/*`, so a co-located `src/**/*.test.ts` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. - Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent *enter* (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the *real* clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews diff --git a/clients/cli/__tests__/helpers/oauth-test-fakes.ts b/clients/cli/__tests__/helpers/oauth-test-fakes.ts index 517a5c12a..b53e15703 100644 --- a/clients/cli/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/cli/__tests__/helpers/oauth-test-fakes.ts @@ -26,7 +26,9 @@ export function makeFakeCliOAuthClient( // the same way the tui App spies are typed. Note this does NOT extend to // `overrides`: a caller passing a bare `vi.fn()` gets `Mock<(...args) => any>`, // assignable to any member, so an override's implementation is unchecked — the - // factory guarantees the object's shape, not each override's signature. + // factory guarantees the object's shape, not each override's signature. (And + // since `exactOptionalPropertyTypes` is off, `Partial<…>` even admits an + // explicit `undefined` for a required member.) return { connect: vi.fn().mockResolvedValue(undefined), disconnect: vi @@ -52,7 +54,9 @@ export function makeFakeCliOAuthClient( * A full {@link InspectorServerSettings} with representative defaults for its * required fields (the timeouts at 0 = "SDK default", `taskTtl` / * `maxFetchRequests` at their product defaults, empty lists elsewhere). Pass - * `overrides` for the field(s) under test (e.g. `{ enterpriseManaged: true }`). + * `overrides` for the field(s) under test (e.g. `{ enterpriseManaged: true }`); + * as a `Partial<…>` with `exactOptionalPropertyTypes` off it also admits an + * explicit `undefined` for a required field, so don't rely on it to reject one. */ export function makeFakeServerSettings( overrides: Partial = {}, diff --git a/clients/tui/__tests__/helpers/oauth-test-fakes.ts b/clients/tui/__tests__/helpers/oauth-test-fakes.ts index 2a69d236a..efff339e1 100644 --- a/clients/tui/__tests__/helpers/oauth-test-fakes.ts +++ b/clients/tui/__tests__/helpers/oauth-test-fakes.ts @@ -10,7 +10,9 @@ import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; * `maxFetchRequests` at their product defaults, empty lists elsewhere). Lets a * test supply only the field(s) under test (e.g. `{ enterpriseManaged: true }`) * while `tsc` still sees a complete object, avoiding `as unknown as` casts (see - * #1791 and the AGENTS.md `as`-cast policy). + * #1791 and the AGENTS.md `as`-cast policy). As a `Partial<…>` with + * `exactOptionalPropertyTypes` off, `overrides` also admits an explicit + * `undefined` for a required field, so don't rely on it to reject one. */ export function makeFakeServerSettings( overrides: Partial = {}, diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index d2ab206e2..abf05bc13 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -6,10 +6,12 @@ /** * Names of scripts transitively reachable from `entry` by following `npm run - * ` references within a single manifest's `scripts`. A gate harvested from - * a script that nothing reachable from `entry` invokes gates nothing, so callers - * restrict to this set to assert "CI actually runs this", not merely "the script - * exists". + * ` references within a single manifest's `scripts`, plus npm's implicit + * `pre`/`post` lifecycle hooks (npm runs those around `` + * 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 + * `entry` invokes gates nothing, so callers restrict to this set to assert "CI + * actually runs this", not merely "the script exists". */ export function reachableScripts(scripts, entry = "validate") { const reached = new Set(); @@ -19,6 +21,9 @@ export function reachableScripts(scripts, entry = "validate") { const name = queue.shift(); if (reached.has(name)) continue; reached.add(name); + // npm runs pre/post implicitly around . + for (const hook of [`pre${name}`, `post${name}`]) + if (typeof scripts?.[hook] === "string") queue.push(hook); const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; for (const m of cmd.matchAll(runRef)) queue.push(m[1]); diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 53d39bc5f..54e98a20f 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -61,17 +61,27 @@ const isRequiredSource = (rel) => * harvested from **every** script reachable from `typecheck` (not just the one * string) so a delegating `typecheck` (`npm run typecheck:src && …`) still * counts — matching how `verify-format-coverage.mjs` harvests globs across - * reachable scripts. + * reachable scripts. Splits each script on `&&`/`||`/`;` so a flag on one + * command doesn't leak onto another. Returns `{ projects, neutered }`: + * `neutered` names any `-p` whose own command carries `--noCheck` or + * `--listFilesOnly` — a pass that lists files without type-checking them, which + * would otherwise satisfy the guard while checking nothing. */ function typecheckProjects(scripts) { const projects = []; + const neutered = []; for (const name of reachableScripts(scripts, "typecheck")) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; - for (const m of cmd.matchAll(/(?:-p|--project)\s+(\S+)/g)) - projects.push(m[1]); + for (const segment of cmd.split(/&&|\|\||;/)) { + const disabling = /--noCheck|--listFilesOnly/.exec(segment); + for (const m of segment.matchAll(/(?:-p|--project)\s+(\S+)/g)) { + if (disabling) neutered.push({ project: m[1], flag: disabling[0] }); + else projects.push(m[1]); + } + } } - return projects; + return { projects, neutered }; } /** @@ -193,11 +203,17 @@ for (const clientDir of CLIENTS) { const scripts = JSON.parse( readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), ).scripts; - const projects = typecheckProjects(scripts); - if (projects.length === 0) { + const { projects, neutered } = typecheckProjects(scripts); + for (const { project, flag } of neutered) { failures.push( - `${clientDir}: its \`typecheck\` script names no \`-p \` — nothing is typechecked.`, + `${clientDir}: its \`typecheck\` runs \`-p ${project}\` with \`${flag}\` — that pass lists files without type-checking them, so it gates nothing.`, ); + } + if (projects.length === 0) { + if (neutered.length === 0) + failures.push( + `${clientDir}: its \`typecheck\` script names no \`-p \` — nothing is typechecked.`, + ); continue; } const covered = new Set(); From d39439cad362fa14ce37ff9b6dee527650302566 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:13:07 -0400 Subject: [PATCH 12/39] =?UTF-8?q?fix:=20round-11=20=E2=80=94=20launcher=20?= =?UTF-8?q?build=20excludes=20tests;=20guard=20catches=20all=20noCheck=20v?= =?UTF-8?q?ectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-11 @claude review of #1799: - Finding 1 (shipping bug): clients/launcher/tsconfig.json now excludes `**/*.test.{ts,tsx}` (matching cli/tui). launcher's `build` is an EMITTING tsc over src/**, and clients/launcher/build is in the root `files` allowlist, so a co-located `src/**/*.test.ts` would have been compiled into the published tarball. Excluding it also makes such a test land in no tsconfig project, so verify:typecheck-coverage catches it — making the AGENTS.md placement rule literally true for all three clients. - Finding 2: verify-typecheck-coverage now catches both remaining ways a typecheck could check nothing: (a) case-insensitive `--noCheck`/`--nocheck`/ `--listFilesOnly` on the command, and (b) `noCheck` set in the tsconfig itself (detected via `tsc --showConfig`, which surfaces it from the config or its extends chain). Verified both fail the guard. - Nits 3 & 4: restructured into an explicit gate-integrity phase (wiring + neutered + config-noCheck + no-projects) that reports under its own accurate header and exits BEFORE the file-coverage pass — so an inert gate no longer prints file-remediation advice, nor buries the real cause under an "in no tsconfig project" line for every file that gate covered. The one CIMD OAuth E2E integration test that flaked in the coverage run passes 32/32 in isolation and on re-run; unrelated to these script/config changes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/launcher/tsconfig.json | 7 +- scripts/verify-typecheck-coverage.mjs | 146 +++++++++++++++----------- 2 files changed, 93 insertions(+), 60 deletions(-) diff --git a/clients/launcher/tsconfig.json b/clients/launcher/tsconfig.json index 4b705d67e..b984bd23b 100644 --- a/clients/launcher/tsconfig.json +++ b/clients/launcher/tsconfig.json @@ -9,5 +9,10 @@ "skipLibCheck": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "build"] + // Exclude tests so `build` (an emitting `tsc`) never compiles a co-located + // `src/**/*.test.ts` into `build/` — which ships in the published tarball. It + // also means such a test lands in no tsconfig project, so verify:typecheck- + // coverage catches it, matching cli/tui. (Tests live in `__tests__/`, checked + // via tsconfig.test.json.) + "exclude": ["node_modules", "build", "**/*.test.ts", "**/*.test.tsx"] } diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 54e98a20f..0bc0a453f 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -63,9 +63,11 @@ const isRequiredSource = (rel) => * counts — matching how `verify-format-coverage.mjs` harvests globs across * reachable scripts. Splits each script on `&&`/`||`/`;` so a flag on one * command doesn't leak onto another. Returns `{ projects, neutered }`: - * `neutered` names any `-p` whose own command carries `--noCheck` or - * `--listFilesOnly` — a pass that lists files without type-checking them, which - * would otherwise satisfy the guard while checking nothing. + * `neutered` names any `-p` whose own command carries `--noCheck`/`--nocheck` or + * `--listFilesOnly` (matched case-insensitively — tsc's option parsing is) — a + * pass that lists files without type-checking them, which would otherwise + * satisfy the guard while checking nothing. The config-file form (`noCheck` set + * in the tsconfig) is caught separately by {@link projectDisablesChecking}. */ function typecheckProjects(scripts) { const projects = []; @@ -74,7 +76,7 @@ function typecheckProjects(scripts) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; for (const segment of cmd.split(/&&|\|\||;/)) { - const disabling = /--noCheck|--listFilesOnly/.exec(segment); + const disabling = /--noCheck|--listFilesOnly/i.exec(segment); for (const m of segment.matchAll(/(?:-p|--project)\s+(\S+)/g)) { if (disabling) neutered.push({ project: m[1], flag: disabling[0] }); else projects.push(m[1]); @@ -84,6 +86,26 @@ function typecheckProjects(scripts) { return { projects, neutered }; } +/** + * Whether the tsconfig `project` sets `noCheck` (which disables type-checking as + * thoroughly as the CLI flag, but can't be seen in the `typecheck` script + * string). `tsc --showConfig` emits the merged compilerOptions, surfacing a + * `noCheck` from the config or its `extends` chain. Best-effort: on any error + * (e.g. an unreadable config, already reported elsewhere) it returns false. + */ +function projectDisablesChecking(clientDir, project) { + try { + const out = execFileSync( + "npx", + ["--no-install", "tsc", "-p", project, "--showConfig"], + { cwd: path.join(repoRoot, clientDir), encoding: "utf8" }, + ); + return JSON.parse(out)?.compilerOptions?.noCheck === true; + } catch { + return false; + } +} + /** * Repo-relative POSIX paths of the files a project typechecks. Absolute paths * outside the repo root (lib.d.ts) and anything under `node_modules` are @@ -145,79 +167,85 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } -/** - * Assert the gate is wired: the root `validate` chain invokes each client's - * `validate`, and each client's `validate` reaches its `typecheck`. Returns a - * list of human-readable wiring failures. Without this the guard could run a - * `typecheck` script that CI never invokes and still report OK. - */ -function wiringFailures() { - const failures = []; - const rootPkg = JSON.parse( - readFileSync(path.join(repoRoot, "package.json"), "utf8"), +// --------------------------------------------------------------------------- +// Phase 1 — gate integrity: is each client's `typecheck` actually run, and does +// it actually type-check? Reported (and exited) before the file-coverage pass +// so a mis-wired / inert gate isn't buried under a flood of consequent +// "in no tsconfig project" lines (which would list every file that gate covered). +// Also records, per client, the projects that genuinely type-check, for phase 2. +// --------------------------------------------------------------------------- +const integrity = []; +const checkingProjects = new Map(); +const rootPkg = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), +); + +// Vouch for the sibling guard — a guard can't detect being unrun itself, but the +// two can each assert the other is still wired into `validate`, so dropping +// either is caught here (only deleting both slips through). +if (!rootReachesScript(rootPkg.scripts, "verify:format-coverage")) { + integrity.push( + "the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.", ); +} - // Assert the sibling guard is still wired — a guard can't detect being unrun - // itself, but each can vouch for the other, so dropping either from `validate` - // is caught here (only deleting both slips through). - if (!rootReachesScript(rootPkg.scripts, "verify:format-coverage")) { - failures.push( - "the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.", +for (const clientDir of CLIENTS) { + checkingProjects.set(clientDir, []); + if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { + integrity.push( + `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` — its typecheck isn't invoked by CI.`, ); + continue; } - - for (const clientDir of CLIENTS) { - if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { - failures.push( - `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` — its typecheck isn't invoked by CI.`, - ); - continue; - } - const scripts = JSON.parse( - readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), - ).scripts; - if (!reachableScripts(scripts, "validate").has("typecheck")) { - failures.push( - `${clientDir}: \`typecheck\` is not reachable from its \`validate\` — the typecheck it measures gates nothing.`, + const scripts = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ).scripts; + if (!reachableScripts(scripts, "validate").has("typecheck")) { + integrity.push( + `${clientDir}: \`typecheck\` is not reachable from its \`validate\` — the typecheck it measures gates nothing.`, + ); + continue; + } + const { projects, neutered } = typecheckProjects(scripts); + for (const { project, flag } of neutered) + integrity.push( + `${clientDir}: its \`typecheck\` runs \`-p ${project}\` with \`${flag}\` — that pass lists files without type-checking them, so it gates nothing.`, + ); + const checking = projects.filter((project) => { + if (projectDisablesChecking(clientDir, project)) { + integrity.push( + `${clientDir}: \`-p ${project}\` sets \`noCheck\` in its tsconfig — that pass lists files without type-checking them, so it gates nothing.`, ); + return false; } - } - return failures; + return true; + }); + if (checking.length === 0 && neutered.length === 0) + integrity.push( + `${clientDir}: its \`typecheck\` names no \`-p \` — nothing is typechecked.`, + ); + checkingProjects.set(clientDir, checking); } -const wiring = wiringFailures(); -if (wiring.length > 0) { +if (integrity.length > 0) { console.error( - `verify:typecheck-coverage — ${wiring.length} wiring issue(s): a typecheck gate is not actually run:\n`, + `verify:typecheck-coverage — ${integrity.length} gate-integrity issue(s): a typecheck gate is not run, or runs but checks nothing:\n`, ); - for (const f of wiring) console.error(" " + f); + for (const f of integrity) console.error(" " + f); console.error( - "\nRestore the `typecheck` link in the client's `validate` (and the `validate:` link in the root `validate`).", + "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass.", ); process.exit(1); } +// --------------------------------------------------------------------------- +// Phase 2 — file coverage: every tracked source file lands in a checking project. +// --------------------------------------------------------------------------- let totalChecked = 0; const failures = []; for (const clientDir of CLIENTS) { - const scripts = JSON.parse( - readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), - ).scripts; - const { projects, neutered } = typecheckProjects(scripts); - for (const { project, flag } of neutered) { - failures.push( - `${clientDir}: its \`typecheck\` runs \`-p ${project}\` with \`${flag}\` — that pass lists files without type-checking them, so it gates nothing.`, - ); - } - if (projects.length === 0) { - if (neutered.length === 0) - failures.push( - `${clientDir}: its \`typecheck\` script names no \`-p \` — nothing is typechecked.`, - ); - continue; - } const covered = new Set(); - for (const project of projects) + for (const project of checkingProjects.get(clientDir)) for (const f of projectFiles(clientDir, project)) covered.add(f); const tracked = trackedSourceFiles(clientDir); @@ -228,7 +256,7 @@ for (const clientDir of CLIENTS) { if (failures.length > 0) { console.error( - `verify:typecheck-coverage — ${failures.length} issue(s): tracked source files that get no \`tsc\` pass:\n`, + `verify:typecheck-coverage — ${failures.length} tracked source file(s) get no \`tsc\` pass:\n`, ); for (const f of failures) console.error(" " + f); console.error( From 0b7c4768f983739067f39f66d6625b75c65ce66b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:30:14 -0400 Subject: [PATCH 13/39] =?UTF-8?q?fix:=20round-12=20=E2=80=94=20exclude=20.?= =?UTF-8?q?mts/.cts=20tests=20from=20build;=20drop=20false=20integrity=20l?= =?UTF-8?q?ine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-12 @claude review of #1799: - Finding 1 (tarball, again): the round-11 test exclude only covered `.ts`/`.tsx`, so a co-located `src/*.test.mts` still compiled into launcher's shipped `build/`. All three clients now exclude `**/*.test.*` (one glob, covering .ts/.tsx/.mts/.cts) — which is also exactly what AGENTS.md already claims. Verified a `src/probe.test.mts` is no longer emitted and now fails the guard. - Finding 2: the gate-integrity phase no longer prints a false "names no `-p `" line when config-`noCheck` disables projects — the condition now keys off `projects.length` (harvested), not `checking.length` (post- filter), so a client whose projects were all config-disabled shows only the accurate per-project lines. - Nit 3: recorded the boundary on the phase comment — it does not detect shell-level failure suppression (`… || true`, `; exit 0`). - Nit 5: the file-coverage remediation now leads with "for a co-located test, move it to `__tests__/`" (adding it to the src `include` would reintroduce the build emit) before the general add-to-include advice. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/cli/tsconfig.json | 2 +- clients/launcher/tsconfig.json | 4 ++-- clients/tui/tsconfig.json | 2 +- scripts/verify-typecheck-coverage.mjs | 11 ++++++++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/clients/cli/tsconfig.json b/clients/cli/tsconfig.json index 69df707ec..31e268fdb 100644 --- a/clients/cli/tsconfig.json +++ b/clients/cli/tsconfig.json @@ -21,5 +21,5 @@ } }, "include": ["src/**/*", "vitest.config.ts", "tsup.config.ts"], - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"] + "exclude": ["node_modules", "**/*.test.*", "build"] } diff --git a/clients/launcher/tsconfig.json b/clients/launcher/tsconfig.json index b984bd23b..3e0379729 100644 --- a/clients/launcher/tsconfig.json +++ b/clients/launcher/tsconfig.json @@ -10,9 +10,9 @@ }, "include": ["src/**/*"], // Exclude tests so `build` (an emitting `tsc`) never compiles a co-located - // `src/**/*.test.ts` into `build/` — which ships in the published tarball. It + // `src/**/*.test.*` into `build/` — which ships in the published tarball. It // also means such a test lands in no tsconfig project, so verify:typecheck- // coverage catches it, matching cli/tui. (Tests live in `__tests__/`, checked // via tsconfig.test.json.) - "exclude": ["node_modules", "build", "**/*.test.ts", "**/*.test.tsx"] + "exclude": ["node_modules", "build", "**/*.test.*"] } diff --git a/clients/tui/tsconfig.json b/clients/tui/tsconfig.json index 511f73083..31997f4e8 100644 --- a/clients/tui/tsconfig.json +++ b/clients/tui/tsconfig.json @@ -33,5 +33,5 @@ "vitest.config.ts", "tsup.config.ts" ], - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "build"] + "exclude": ["node_modules", "**/*.test.*", "build"] } diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 0bc0a453f..4b5893e9b 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -173,6 +173,8 @@ function trackedSourceFiles(clientDir) { // so a mis-wired / inert gate isn't buried under a flood of consequent // "in no tsconfig project" lines (which would list every file that gate covered). // Also records, per client, the projects that genuinely type-check, for phase 2. +// Boundary: this does NOT detect shell-level failure suppression on the pass +// (`… || true`, `; exit 0`) — a pass that runs and checks but can't fail CI. // --------------------------------------------------------------------------- const integrity = []; const checkingProjects = new Map(); @@ -220,7 +222,10 @@ for (const clientDir of CLIENTS) { } return true; }); - if (checking.length === 0 && neutered.length === 0) + // Fire only when nothing was harvested at all — not when projects WERE named + // but every one is neutered (command flag) or config-disabled (`projects` was + // non-empty in that case; those get their own lines above). + if (projects.length === 0 && neutered.length === 0) integrity.push( `${clientDir}: its \`typecheck\` names no \`-p \` — nothing is typechecked.`, ); @@ -260,10 +265,10 @@ if (failures.length > 0) { ); for (const f of failures) console.error(" " + f); console.error( - "\nAdd the file to a client's `tsconfig.json` / `tsconfig.test.json` `include`", + "\nFor a co-located test, move it to `__tests__/` (never add it to the src `include` — the build would emit it).", ); console.error( - "(a top-level config file that the build config's `rootDir` rejects goes in the test project). See AGENTS.md.", + "Otherwise add the file to a client's `tsconfig.json` / `tsconfig.test.json` `include` (a top-level config the build config's `rootDir` rejects goes in the test project). See AGENTS.md.", ); process.exit(1); } From b7395674841a9f6ac281480849accaebdbdbe431 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 02:52:03 -0400 Subject: [PATCH 14/39] =?UTF-8?q?chore(scripts):=20round-13=20=E2=80=94=20?= =?UTF-8?q?implicit-tsconfig=20+=20derived=20clients;=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-13 @claude review of #1799: - Finding 1: verify-typecheck-coverage now understands a `tsc` command with no `-p`/`-b` project flag — it resolves the implicit `./tsconfig.json` (tsc's own default), so the idiomatic `tsc --noEmit` form counts as coverage instead of producing false "in no tsconfig project" failures. Also harvests `-b`/`--build` project paths. Verified `tsc --noEmit && tsc --noEmit -p tsconfig.test.json` now reports OK. - Finding 2: CLIENTS is no longer hardcoded — it's derived from the `cd clients/ && npm run validate` entries in the root `validate` chain (minus an explicit web exclusion), so a new Node client is auto-required, matching the repo-wide reach of its sibling verify-format-coverage. Added a guard-would-check-nothing bail if the derivation yields an empty set. Verified a new `clients/newclient` is picked up and its uncovered file flagged. - Nit 3: the phase-2 remediation now leads with the general add-to-`include` advice and only appends the "move it to __tests__/" note when a failing path is actually a test file (was misdirecting plain files). - Nit 4: AGENTS.md no longer says the test config includes "only" __tests__ (launcher's also includes its vitest.config.ts). (Two different web integration tests each flaked once under parallel load during the coverage runs — both pass in isolation and on a clean re-run; timing- sensitive and unrelated to these script/doc-only changes.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- scripts/lib/npm-scripts.mjs | 14 +++++ scripts/verify-typecheck-coverage.mjs | 84 ++++++++++++++++++--------- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b95a8b4d0..4eda7ae65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* - **Side-by-side (`.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside. - - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes only `__tests__/**/*`, so a co-located `src/**/*.test.ts` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. + - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. - Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent *enter* (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the *real* clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index abf05bc13..ef7c52a2d 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -59,3 +59,17 @@ export function rootRunsClientValidate(rootScripts, clientDir) { export function rootReachesScript(rootScripts, scriptName) { return reachableScripts(rootScripts, "validate").has(scriptName); } + +/** + * The `clients/` dirs the root `validate` chain runs via `cd clients/ + * && npm run validate`, in encounter order. Lets a guard derive its client list + * from the chain rather than a hardcoded list, so a new client is picked up + * automatically. + */ +export function clientsRunByRootValidate(rootScripts) { + const dirs = []; + for (const c of rootReachedCommands(rootScripts)) + for (const m of c.matchAll(/cd (clients\/[\w-]+)\s*&&\s*npm run validate/g)) + if (!dirs.includes(m[1])) dirs.push(m[1]); + return dirs; +} diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 4b5893e9b..1ff1399b9 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -31,6 +31,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + clientsRunByRootValidate, reachableScripts, rootReachesScript, rootRunsClientValidate, @@ -41,11 +42,24 @@ const repoRoot = path.resolve( "..", ); -// The clients whose `typecheck` runs explicit `-p ` passes (#1791). -// web is intentionally out: it typechecks via `tsc -b` over a project-reference -// graph (app/node/storybook/test) that already reaches its whole tree. Kept an -// explicit list, mirroring `verify-format-coverage.mjs`'s `MANIFESTS`. -const CLIENTS = ["clients/cli", "clients/tui", "clients/launcher"]; +const rootPkg = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), +); + +// web is intentionally out of this guard: it typechecks via `tsc -b` over a +// project-reference graph (app/node/storybook/test) that already reaches its +// whole tree, and has no `-p`-based `typecheck` script for this guard to model. +const EXCLUDED_CLIENTS = new Set(["clients/web"]); + +// The Node clients this guard covers (#1791). Derived from the `cd clients/ +// && npm run validate` entries in the root `validate` chain (minus the excluded +// ones), so a NEW node client is picked up automatically — unlike a hardcoded +// list, which would silently leave a new client's `__tests__` ungated (its +// repo-wide sibling `verify-format-coverage` would still catch the *format* gap, +// but not the typecheck one). +const CLIENTS = clientsRunByRootValidate(rootPkg.scripts).filter( + (dir) => !EXCLUDED_CLIENTS.has(dir), +); // TypeScript source extensions this guard requires a tsc pass for. Matches its // sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom @@ -57,17 +71,20 @@ const isRequiredSource = (rel) => /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); /** - * The tsconfig projects a client's `typecheck` names via `-p`/`--project`, - * harvested from **every** script reachable from `typecheck` (not just the one - * string) so a delegating `typecheck` (`npm run typecheck:src && …`) still - * counts — matching how `verify-format-coverage.mjs` harvests globs across - * reachable scripts. Splits each script on `&&`/`||`/`;` so a flag on one - * command doesn't leak onto another. Returns `{ projects, neutered }`: - * `neutered` names any `-p` whose own command carries `--noCheck`/`--nocheck` or - * `--listFilesOnly` (matched case-insensitively — tsc's option parsing is) — a - * pass that lists files without type-checking them, which would otherwise - * satisfy the guard while checking nothing. The config-file form (`noCheck` set - * in the tsconfig) is caught separately by {@link projectDisablesChecking}. + * The tsconfig projects a client's `typecheck` names, harvested from **every** + * script reachable from `typecheck` (not just the one string) so a delegating + * `typecheck` (`npm run typecheck:src && …`) still counts — matching how + * `verify-format-coverage.mjs` harvests globs across reachable scripts. Splits + * each script on `&&`/`||`/`;` so a flag on one command doesn't leak onto + * another. Each `tsc` command's project comes from `-p`/`--project` (or a + * `-b`/`--build` path); a `tsc` command with **no** project flag resolves the + * implicit `./tsconfig.json` (tsc's own default), so that idiomatic form counts + * too. Returns `{ projects, neutered }`: `neutered` names any project whose own + * command carries `--noCheck`/`--nocheck` or `--listFilesOnly` (matched + * case-insensitively — tsc's option parsing is) — a pass that lists files + * without type-checking them, which would otherwise satisfy the guard while + * checking nothing. The config-file form (`noCheck` set in the tsconfig) is + * caught separately by {@link projectDisablesChecking}. */ function typecheckProjects(scripts) { const projects = []; @@ -76,10 +93,17 @@ function typecheckProjects(scripts) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; for (const segment of cmd.split(/&&|\|\||;/)) { + if (!/\btsc\b/.test(segment)) continue; // only tsc commands name projects const disabling = /--noCheck|--listFilesOnly/i.exec(segment); - for (const m of segment.matchAll(/(?:-p|--project)\s+(\S+)/g)) { - if (disabling) neutered.push({ project: m[1], flag: disabling[0] }); - else projects.push(m[1]); + // `-p`/`--project`/`-b`/`--build` each take a project path (the capture + // rejects a following flag); a tsc command with none uses ./tsconfig.json. + const named = [ + ...segment.matchAll(/(?:-p|--project|-b|--build)\s+([^\s-]\S*)/g), + ].map((m) => m[1]); + if (named.length === 0) named.push("tsconfig.json"); + for (const project of named) { + if (disabling) neutered.push({ project, flag: disabling[0] }); + else projects.push(project); } } } @@ -176,11 +200,17 @@ function trackedSourceFiles(clientDir) { // Boundary: this does NOT detect shell-level failure suppression on the pass // (`… || true`, `; exit 0`) — a pass that runs and checks but can't fail CI. // --------------------------------------------------------------------------- +// Derivation must not silently yield an empty set (a broken root `validate` or a +// changed `cd clients/` idiom would otherwise make the whole guard a no-op). +if (CLIENTS.length === 0) { + console.error( + "verify:typecheck-coverage — derived no clients from the root `validate` chain (expected `cd clients/ && npm run validate` entries). The guard would check nothing; fix the derivation.", + ); + process.exit(1); +} + const integrity = []; const checkingProjects = new Map(); -const rootPkg = JSON.parse( - readFileSync(path.join(repoRoot, "package.json"), "utf8"), -); // Vouch for the sibling guard — a guard can't detect being unrun itself, but the // two can each assert the other is still wired into `validate`, so dropping @@ -265,11 +295,13 @@ if (failures.length > 0) { ); for (const f of failures) console.error(" " + f); console.error( - "\nFor a co-located test, move it to `__tests__/` (never add it to the src `include` — the build would emit it).", - ); - console.error( - "Otherwise add the file to a client's `tsconfig.json` / `tsconfig.test.json` `include` (a top-level config the build config's `rootDir` rejects goes in the test project). See AGENTS.md.", + "\nAdd the file to a client's `tsconfig.json` / `tsconfig.test.json` `include` (a top-level config the build config's `rootDir` rejects goes in the test project).", ); + if (failures.some((f) => /\.test\./.test(f))) + console.error( + "For a co-located test, instead move it to `__tests__/` — adding it to the src `include` would make the build emit it.", + ); + console.error("See AGENTS.md."); process.exit(1); } From 9a0edacf11b4b0a6710588079318438230dc39c4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:08:32 -0400 Subject: [PATCH 15/39] =?UTF-8?q?chore(scripts):=20round-14=20=E2=80=94=20?= =?UTF-8?q?enumerate=20clients=20from=20disk=20(fail-closed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-14 @claude review of #1799: - Finding 1: replace the root-chain `CLIENTS` derivation (round 13) with a disk enumeration — every `clients/` whose package.json has a `typecheck` script. The chain derivation was fail-open: a root-chain edit that kept running a client but didn't match the strict regex silently dropped it from the required set (112→53 files, still green). Disk is fail-closed — a client can't be silently dropped, a new client with a typecheck is auto-required, and web (no typecheck script; builds via `tsc -b`) is excluded on its own, so the explicit exclusion is gone. The existing `rootRunsClientValidate` wiring check still verifies the root chain runs each — now LOUD when it doesn't. Verified: `cd ./clients/tui && …` now hard-fails the integrity phase instead of silently shrinking coverage; `cd clients/tui && npm run build && npm run validate` stays gated. Removed the now-unused `clientsRunByRootValidate` from the shared lib. - Nits 2 & 3: documented the two unreachable-today limitations on typecheckProjects — a solution-style `tsc -b` (empty `files` + `references`) would under-report, and the implicit-tsconfig fallback assumes no file operands. - Nit 4: refreshed the file header to mention `-b`/`--build` + the implicit `./tsconfig.json` (only the JSDoc had been updated). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/lib/npm-scripts.mjs | 14 ------ scripts/verify-typecheck-coverage.mjs | 62 +++++++++++++++++++-------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index ef7c52a2d..abf05bc13 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -59,17 +59,3 @@ export function rootRunsClientValidate(rootScripts, clientDir) { export function rootReachesScript(rootScripts, scriptName) { return reachableScripts(rootScripts, "validate").has(scriptName); } - -/** - * The `clients/` dirs the root `validate` chain runs via `cd clients/ - * && npm run validate`, in encounter order. Lets a guard derive its client list - * from the chain rather than a hardcoded list, so a new client is picked up - * automatically. - */ -export function clientsRunByRootValidate(rootScripts) { - const dirs = []; - for (const c of rootReachedCommands(rootScripts)) - for (const m of c.matchAll(/cd (clients\/[\w-]+)\s*&&\s*npm run validate/g)) - if (!dirs.includes(m[1])) dirs.push(m[1]); - return dirs; -} diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 1ff1399b9..612a8047b 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -23,15 +23,15 @@ // nothing is typechecked ("gate silently stops gating"). // // Source of truth is the `typecheck` scripts themselves — this parser reads the -// `-p`/`--project` args out of every script reachable from `typecheck`, so +// `-p`/`--project`/`-b`/`--build` args (and the implicit `./tsconfig.json` a +// bare `tsc` resolves) out of every script reachable from `typecheck`, so // adding/removing a project is reflected here with no second list to keep in sync. import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { - clientsRunByRootValidate, reachableScripts, rootReachesScript, rootRunsClientValidate, @@ -46,20 +46,36 @@ const rootPkg = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); -// web is intentionally out of this guard: it typechecks via `tsc -b` over a -// project-reference graph (app/node/storybook/test) that already reaches its -// whole tree, and has no `-p`-based `typecheck` script for this guard to model. -const EXCLUDED_CLIENTS = new Set(["clients/web"]); +/** + * The Node clients this guard covers (#1791): every `clients/` dir whose + * `package.json` declares a `typecheck` script. Enumerated from **disk** — not a + * hardcoded list (which wouldn't pick up a new client) and not the root + * `validate` chain (a root-chain edit could then silently drop a client from the + * required set, fail-open — the guard would go green while gating less). Disk is + * fail-closed: a new client with a `typecheck` is auto-required, and none can be + * silently dropped. `clients/web` has no `typecheck` script (it builds via `tsc + * -b` over a project-reference graph that reaches its whole tree), so it's + * excluded on its own — no explicit exclusion needed. The `rootRunsClientValidate` + * wiring check below then verifies the root chain actually runs each of these. + */ +function nodeClients() { + const clientsDir = path.join(repoRoot, "clients"); + const dirs = []; + for (const name of readdirSync(clientsDir)) { + let scripts; + try { + scripts = JSON.parse( + readFileSync(path.join(clientsDir, name, "package.json"), "utf8"), + ).scripts; + } catch { + continue; // no readable package.json (not a client dir) + } + if (typeof scripts?.typecheck === "string") dirs.push(`clients/${name}`); + } + return dirs.sort(); +} -// The Node clients this guard covers (#1791). Derived from the `cd clients/ -// && npm run validate` entries in the root `validate` chain (minus the excluded -// ones), so a NEW node client is picked up automatically — unlike a hardcoded -// list, which would silently leave a new client's `__tests__` ungated (its -// repo-wide sibling `verify-format-coverage` would still catch the *format* gap, -// but not the typecheck one). -const CLIENTS = clientsRunByRootValidate(rootPkg.scripts).filter( - (dir) => !EXCLUDED_CLIENTS.has(dir), -); +const CLIENTS = nodeClients(); // TypeScript source extensions this guard requires a tsc pass for. Matches its // sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom @@ -85,6 +101,14 @@ const isRequiredSource = (rel) => * without type-checking them, which would otherwise satisfy the guard while * checking nothing. The config-file form (`noCheck` set in the tsconfig) is * caught separately by {@link projectDisablesChecking}. + * + * Two limitations, both unreachable today (cli/tui/launcher use plain `-p` + * `--noEmit` passes; web, the only `tsc -b` client, is out of scope): a + * **solution-style** `-b` config (`"files": []` + `references`) is run here as + * `-p … --listFilesOnly`, which lists nothing — a client adopting it would need + * its `references` expanded to their paths. And the implicit-`./tsconfig.json` + * fallback assumes **no file operands** — `tsc ` ignores the config and + * checks only that file, but would be credited the whole config's file list. */ function typecheckProjects(scripts) { const projects = []; @@ -200,11 +224,11 @@ function trackedSourceFiles(clientDir) { // Boundary: this does NOT detect shell-level failure suppression on the pass // (`… || true`, `; exit 0`) — a pass that runs and checks but can't fail CI. // --------------------------------------------------------------------------- -// Derivation must not silently yield an empty set (a broken root `validate` or a -// changed `cd clients/` idiom would otherwise make the whole guard a no-op). +// Enumeration must not silently yield an empty set (a moved `clients/` dir or a +// renamed `typecheck` script would otherwise make the whole guard a no-op). if (CLIENTS.length === 0) { console.error( - "verify:typecheck-coverage — derived no clients from the root `validate` chain (expected `cd clients/ && npm run validate` entries). The guard would check nothing; fix the derivation.", + "verify:typecheck-coverage — found no `clients/*` dir with a `typecheck` script. The guard would check nothing; fix the enumeration.", ); process.exit(1); } From 505cd4889dd73b6c447b9998c53e506e6c98363c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:27:58 -0400 Subject: [PATCH 16/39] =?UTF-8?q?chore(scripts):=20round-15=20=E2=80=94=20?= =?UTF-8?q?enroll-or-exempt=20enumeration;=20normalize=20`cd=20./`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-15 @claude review of #1799: - Finding 1: the disk enumeration keyed on the presence of a script named `typecheck`, so renaming it silently dropped the client (112→53, still green) — contradicting the docstring. Now every `clients/*` with a readable package.json must EITHER declare a `typecheck` (→ enrolled) OR be in an explicit EXEMPT map (`clients/web`, reason: `tsc -b`); anything else is a hard gate-integrity failure. This is fail-closed on the rename axis too, and web's skip is a stated decision rather than an accident of which scripts it declares — so web gaining a `typecheck` no longer auto-enrolls it into the solution-`-b` false failures. Verified: renaming tui's `typecheck` now hard-fails; a `tsc -b` on web stays exempt. - Nit 3: `rootRunsClientValidate` normalizes a leading `./`, so `cd ./clients/tui && npm run validate` (which genuinely runs it) is no longer a false "no longer runs" alarm. - Nit 2: README/AGENTS/script-header prose no longer names a fixed cli/tui/launcher scope — they describe the disk-discovered "every clients/* with a typecheck script (web exempt)" rule. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 4 +- README.md | 2 +- scripts/lib/npm-scripts.mjs | 6 ++- scripts/verify-typecheck-coverage.mjs | 65 +++++++++++++++++---------- 4 files changed, 49 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4eda7ae65..e33b56727 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,10 +229,10 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in cli/tui/launcher lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` in cli/tui/launcher that lands in no project — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/README.md b/README.md index f52a90213..093f6c332 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (cli/tui/launcher) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Also asserts the gate is wired (each `typecheck` 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 (every `clients/*` that declares a `typecheck` script — cli/tui/launcher today, auto-discovered from disk) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Also asserts the gate is wired (each `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index abf05bc13..12b8d474b 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -46,7 +46,11 @@ function rootReachedCommands(rootScripts) { */ export function rootRunsClientValidate(rootScripts, clientDir) { return rootReachedCommands(rootScripts).some( - (c) => c.includes(`cd ${clientDir}`) && /npm run validate/.test(c), + // Normalize a leading `./` (`cd ./clients/x` genuinely runs the client) so + // that path form isn't a false "no longer runs" alarm. + (c) => + c.replace(/cd \.\//g, "cd ").includes(`cd ${clientDir}`) && + /npm run validate/.test(c), ); } diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 612a8047b..9382a8f40 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node // Durable guard for the "every tracked source file gets a `tsc` pass" invariant -// that #1791 established for the Node clients (cli, tui, launcher). A tsconfig +// that #1791 established for the Node clients — every `clients/*` that declares a +// `typecheck` script (cli, tui, launcher today; a new one is picked up from disk +// automatically, see nodeClients). A tsconfig // project only typechecks the files its `include`/`files` name plus whatever // those transitively import — so a new top-level `.ts` (a fresh config file, a // new test helper) can silently fall outside every project and get no @@ -46,36 +48,50 @@ const rootPkg = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); +// Clients this guard deliberately does NOT gate, with the reason. `clients/web` +// typechecks via `tsc -b` over a project-reference graph (app/node/storybook/ +// test) this guard can't model. An exemption is explicit so it's a stated +// decision, not an accident of which scripts a manifest happens to declare. +const EXEMPT = new Map([ + ["clients/web", "typechecks via `tsc -b` over project references"], +]); + /** - * The Node clients this guard covers (#1791): every `clients/` dir whose - * `package.json` declares a `typecheck` script. Enumerated from **disk** — not a - * hardcoded list (which wouldn't pick up a new client) and not the root - * `validate` chain (a root-chain edit could then silently drop a client from the - * required set, fail-open — the guard would go green while gating less). Disk is - * fail-closed: a new client with a `typecheck` is auto-required, and none can be - * silently dropped. `clients/web` has no `typecheck` script (it builds via `tsc - * -b` over a project-reference graph that reaches its whole tree), so it's - * excluded on its own — no explicit exclusion needed. The `rootRunsClientValidate` - * wiring check below then verifies the root chain actually runs each of these. + * The Node clients this guard covers (#1791): every `clients/` dir with a + * readable `package.json` is required to **either** declare a `typecheck` script + * (→ enrolled) **or** be in {@link EXEMPT} (→ skipped, with a reason). Anything + * else is a gate-integrity failure. Enumerated from **disk** so it's fail-closed + * on every axis: a new client is auto-required (a hardcoded list wouldn't pick + * it up), a client can't be dropped by a root-chain edit (the chain isn't the + * source), and — unlike keying on the presence of a `typecheck` script — + * renaming that script doesn't silently drop the client (it's no longer enrolled + * and isn't exempt, so it hard-fails). Returns `{ clients, problems }`. */ function nodeClients() { const clientsDir = path.join(repoRoot, "clients"); - const dirs = []; - for (const name of readdirSync(clientsDir)) { + const clients = []; + const problems = []; + for (const name of readdirSync(clientsDir).sort()) { + const dir = `clients/${name}`; let scripts; try { scripts = JSON.parse( readFileSync(path.join(clientsDir, name, "package.json"), "utf8"), ).scripts; } catch { - continue; // no readable package.json (not a client dir) + continue; // no readable package.json — not a client dir } - if (typeof scripts?.typecheck === "string") dirs.push(`clients/${name}`); + if (EXEMPT.has(dir)) continue; + if (typeof scripts?.typecheck === "string") clients.push(dir); + else + problems.push( + `${dir}: declares no \`typecheck\` script and isn't in the EXEMPT set — it gets no tsc pass. Add a \`typecheck\` (or exempt it with a reason).`, + ); } - return dirs.sort(); + return { clients, problems }; } -const CLIENTS = nodeClients(); +const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); // TypeScript source extensions this guard requires a tsc pass for. Matches its // sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom @@ -224,18 +240,19 @@ function trackedSourceFiles(clientDir) { // Boundary: this does NOT detect shell-level failure suppression on the pass // (`… || true`, `; exit 0`) — a pass that runs and checks but can't fail CI. // --------------------------------------------------------------------------- -// Enumeration must not silently yield an empty set (a moved `clients/` dir or a -// renamed `typecheck` script would otherwise make the whole guard a no-op). -if (CLIENTS.length === 0) { +// A client that declares no `typecheck` and isn't exempt is a gate-integrity +// failure (seeded here so a renamed/removed `typecheck` is loud, not a silent +// drop). If that leaves nothing enrolled AND surfaced no such problem, the +// enumeration itself is broken (a moved `clients/` dir) — fail rather than no-op. +const integrity = [...enrollmentProblems]; +const checkingProjects = new Map(); +if (CLIENTS.length === 0 && integrity.length === 0) { console.error( - "verify:typecheck-coverage — found no `clients/*` dir with a `typecheck` script. The guard would check nothing; fix the enumeration.", + "verify:typecheck-coverage — found no `clients/*` dir to check. The guard would check nothing; fix the enumeration.", ); process.exit(1); } -const integrity = []; -const checkingProjects = new Map(); - // Vouch for the sibling guard — a guard can't detect being unrun itself, but the // two can each assert the other is still wired into `validate`, so dropping // either is caught here (only deleting both slips through). From f480d6418dc2e5dce776cf7f551e521f536f973d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 03:45:26 -0400 Subject: [PATCH 17/39] =?UTF-8?q?chore(scripts):=20round-16=20=E2=80=94=20?= =?UTF-8?q?anchor=20wiring=20match;=20close=20enumeration=20axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-16 @claude review of #1799: - Finding 1 (only false-PASS): rootRunsClientValidate matched the client dir as a bare substring, so a prefix-sibling (`clients/tui` vs `clients/tui-next`) satisfied the wiring check for the shorter name — dropping a client from the root chain went green if a sibling was present. Now an anchored regex (escaped dir + optional leading `./` + a trailing boundary). Verified the sibling case now hard-fails. - Nit 2: a `clients/*` dir holding tracked TS but no readable package.json is now a gate-integrity failure (was treated as "not a client"), so the "fail-closed on every axis" claim holds. Verified. - Nit 3: readdirSync is wrapped, so a missing `clients/` dir yields the intended "found no clients/* dir" message rather than a raw ENOENT stack. - Nit 4: EXEMPT's reason is now surfaced in the success line ("clients/web exempt: typechecks via tsc -b …"), so the exemption is visible at runtime, not only in source. (Also moved `isRequiredSource` above nodeClients — nit 2's new tracked-TS check calls trackedSourceFiles at module-init, which referenced it before init.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/lib/npm-scripts.mjs | 12 +++--- scripts/verify-typecheck-coverage.mjs | 54 ++++++++++++++++++--------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index 12b8d474b..db3590380 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -45,12 +45,14 @@ function rootReachedCommands(rootScripts) { * — the "gate silently stops gating" failure, one level up. */ export function rootRunsClientValidate(rootScripts, clientDir) { + // Anchored match: an optional leading `./` (`cd ./clients/x` genuinely runs + // the client) and a boundary after the dir, so a prefix-sibling (`clients/x` + // vs `clients/x-next`) can't satisfy the check for the shorter name — a bare + // substring `includes` would let a sibling silently vouch for a dropped client. + const escaped = clientDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`cd \\.?/?${escaped}(?=$|[\\s&;"'])`); return rootReachedCommands(rootScripts).some( - // Normalize a leading `./` (`cd ./clients/x` genuinely runs the client) so - // that path form isn't a false "no longer runs" alarm. - (c) => - c.replace(/cd \.\//g, "cd ").includes(`cd ${clientDir}`) && - /npm run validate/.test(c), + (c) => re.test(c) && /npm run validate/.test(c), ); } diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 9382a8f40..fd7f24364 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -56,6 +56,15 @@ const EXEMPT = new Map([ ["clients/web", "typechecks via `tsc -b` over project references"], ]); +// TypeScript source extensions this guard requires a tsc pass for. Matches its +// sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom +// for shared config here, e.g. `vitest.shared.mts`, so a client `.mts`/`.cts` +// must be gated too). Ambient declaration files are excluded: an unreferenced +// `*.d.{ts,mts,cts}` shim (e.g. web's `vitest.shims.d.ts`) is type-only and not +// a real gap. There are no client `.mts`/`.cts` today; this pre-empts one. +const isRequiredSource = (rel) => + /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); + /** * The Node clients this guard covers (#1791): every `clients/` dir with a * readable `package.json` is required to **either** declare a `typecheck` script @@ -63,25 +72,39 @@ const EXEMPT = new Map([ * else is a gate-integrity failure. Enumerated from **disk** so it's fail-closed * on every axis: a new client is auto-required (a hardcoded list wouldn't pick * it up), a client can't be dropped by a root-chain edit (the chain isn't the - * source), and — unlike keying on the presence of a `typecheck` script — - * renaming that script doesn't silently drop the client (it's no longer enrolled - * and isn't exempt, so it hard-fails). Returns `{ clients, problems }`. + * source), renaming the `typecheck` script doesn't silently drop the client (it + * hard-fails — no longer enrolled, not exempt), and a `clients/*` dir holding + * tracked TS but no manifest hard-fails too (rather than being taken for + * "not a client"). Returns `{ clients, problems }`. */ function nodeClients() { const clientsDir = path.join(repoRoot, "clients"); const clients = []; const problems = []; - for (const name of readdirSync(clientsDir).sort()) { - const dir = `clients/${name}`; + let entries; + try { + entries = readdirSync(clientsDir, { withFileTypes: true }); + } catch { + return { clients, problems }; // `clients/` missing — the caller's bail fires. + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue; + const dir = `clients/${entry.name}`; + if (EXEMPT.has(dir)) continue; let scripts; try { scripts = JSON.parse( - readFileSync(path.join(clientsDir, name, "package.json"), "utf8"), + readFileSync(path.join(clientsDir, entry.name, "package.json"), "utf8"), ).scripts; } catch { - continue; // no readable package.json — not a client dir + // No readable manifest. If the dir still holds tracked TS its source gets + // no tsc pass — a gate hole; otherwise it just isn't a client. + if (trackedSourceFiles(dir).length > 0) + problems.push( + `${dir}: holds tracked TypeScript but has no readable \`package.json\` — its source gets no tsc pass. Add a client manifest with a \`typecheck\` (or exempt it).`, + ); + continue; } - if (EXEMPT.has(dir)) continue; if (typeof scripts?.typecheck === "string") clients.push(dir); else problems.push( @@ -93,15 +116,6 @@ function nodeClients() { const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); -// TypeScript source extensions this guard requires a tsc pass for. Matches its -// sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom -// for shared config here, e.g. `vitest.shared.mts`, so a client `.mts`/`.cts` -// must be gated too). Ambient declaration files are excluded: an unreferenced -// `*.d.{ts,mts,cts}` shim (e.g. web's `vitest.shims.d.ts`) is type-only and not -// a real gap. There are no client `.mts`/`.cts` today; this pre-empts one. -const isRequiredSource = (rel) => - /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); - /** * The tsconfig projects a client's `typecheck` names, harvested from **every** * script reachable from `typecheck` (not just the one string) so a delegating @@ -346,6 +360,10 @@ if (failures.length > 0) { process.exit(1); } +const exemptNote = [...EXEMPT.entries()] + .map(([dir, reason]) => `${dir} exempt: ${reason}`) + .join("; "); console.log( - `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files across ${CLIENTS.length} clients get a tsc pass.`, + `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files across ${CLIENTS.length} clients get a tsc pass` + + (exemptNote ? ` (${exemptNote}).` : "."), ); From ad66aef8c3cf493dfe88d142dd3e5303b53e8a7d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:03:49 -0400 Subject: [PATCH 18/39] =?UTF-8?q?chore(scripts):=20round-17=20=E2=80=94=20?= =?UTF-8?q?share=20quote-aware=20tokenizer=20between=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-17 @claude review of #1799: - Finding 1: the two coverage guards parsed shell strings differently — verify-format-coverage had a quote-aware tokenize(); verify-typecheck-coverage used a raw regex. So a quoted `-p "tsconfig.test.json"` (a form tsc accepts) made the typecheck guard report every file that project covers as uncovered, and a quoted `cd "clients/tui"` in the root chain tripped a false wiring failure. Moved tokenize() (now single+double quote) into the shared lib, imported it into both guards, rewrote typecheckProjects to walk tokens (`-p`/`--project`/`-b`/`--build` → next token), and strip quotes before the `cd` match in rootRunsClientValidate. Verified both quoted forms now pass. - Nit 2: a stale EXEMPT key (a dir that no longer exists) was asserted in the success line unchecked. nodeClients now fails if an EXEMPT key isn't a real `clients/*` directory. Verified. - Nit 3 (build/dist filter belt-and-braces) left as a recorded boundary. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/lib/npm-scripts.mjs | 21 +++++++++++++++-- scripts/verify-format-coverage.mjs | 12 +--------- scripts/verify-typecheck-coverage.mjs | 33 ++++++++++++++++++++------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index db3590380..0d0005d4d 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -4,6 +4,22 @@ // between them (the same rationale `scripts/lib/prod-web-server.mjs` was // extracted under). +/** + * Split a shell-ish command string into args, honoring single and double quotes + * (a quoted arg becomes one token with the quotes stripped). Used by both + * coverage guards so they parse a script's args — quoting is the house style + * here (a quoted prettier glob, `tsc -p "tsconfig.json"`) — the same way. + */ +export function tokenize(command) { + const tokens = []; + const re = /"([^"]*)"|'([^']*)'|(\S+)/g; + let m; + while ((m = re.exec(command)) !== null) { + tokens.push(m[1] ?? m[2] ?? m[3]); + } + return tokens; +} + /** * Names of scripts transitively reachable from `entry` by following `npm run * ` references within a single manifest's `scripts`, plus npm's implicit @@ -49,10 +65,11 @@ export function rootRunsClientValidate(rootScripts, clientDir) { // the client) and a boundary after the dir, so a prefix-sibling (`clients/x` // vs `clients/x-next`) can't satisfy the check for the shorter name — a bare // substring `includes` would let a sibling silently vouch for a dropped client. + // Quotes are stripped first so `cd "clients/x"` (a valid form) still matches. const escaped = clientDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`cd \\.?/?${escaped}(?=$|[\\s&;"'])`); + const re = new RegExp(`cd \\.?/?${escaped}(?=$|[\\s&;])`); return rootReachedCommands(rootScripts).some( - (c) => re.test(c) && /npm run validate/.test(c), + (c) => re.test(c.replace(/["']/g, "")) && /npm run validate/.test(c), ); } diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 5ac4d44b6..8963c8c24 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -21,6 +21,7 @@ import { reachableScripts, rootReachesScript, rootRunsClientValidate, + tokenize, } from "./lib/npm-scripts.mjs"; const repoRoot = path.resolve( @@ -54,17 +55,6 @@ const MANIFESTS = [ "clients/launcher", ]; -/** Split a shell-ish command string into args, honoring double quotes. */ -function tokenize(command) { - const tokens = []; - const re = /"([^"]*)"|(\S+)/g; - let m; - while ((m = re.exec(command)) !== null) { - tokens.push(m[1] !== undefined ? m[1] : m[2]); - } - return tokens; -} - /** * Extract the path/glob args from every `prettier --check …` in a manifest's * scripts that is reachable from `validate`. Restricting to reachable scripts is diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index fd7f24364..f08299f17 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -37,6 +37,7 @@ import { reachableScripts, rootReachesScript, rootRunsClientValidate, + tokenize, } from "./lib/npm-scripts.mjs"; const repoRoot = path.resolve( @@ -111,6 +112,16 @@ function nodeClients() { `${dir}: declares no \`typecheck\` script and isn't in the EXEMPT set — it gets no tsc pass. Add a \`typecheck\` (or exempt it with a reason).`, ); } + // A stale EXEMPT key would otherwise be asserted in the success line while + // naming a dir that no longer exists — the exemption outliving its subject. + const present = new Set( + entries.filter((e) => e.isDirectory()).map((e) => `clients/${e.name}`), + ); + for (const dir of EXEMPT.keys()) + if (!present.has(dir)) + problems.push( + `${dir}: listed in EXEMPT but is not a \`clients/*\` directory — remove the stale exemption.`, + ); return { clients, problems }; } @@ -143,20 +154,26 @@ const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); function typecheckProjects(scripts) { const projects = []; const neutered = []; + const isFlag = (t) => t.startsWith("-"); + const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); for (const name of reachableScripts(scripts, "typecheck")) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; for (const segment of cmd.split(/&&|\|\||;/)) { - if (!/\btsc\b/.test(segment)) continue; // only tsc commands name projects - const disabling = /--noCheck|--listFilesOnly/i.exec(segment); - // `-p`/`--project`/`-b`/`--build` each take a project path (the capture - // rejects a following flag); a tsc command with none uses ./tsconfig.json. - const named = [ - ...segment.matchAll(/(?:-p|--project|-b|--build)\s+([^\s-]\S*)/g), - ].map((m) => m[1]); + const tokens = tokenize(segment); + if (!tokens.includes("tsc")) continue; // only tsc commands name projects + const disabling = tokens.find((t) => + /^--(noCheck|listFilesOnly)$/i.test(t), + ); + // A project path follows `-p`/`--project`/`-b`/`--build`; a tsc command + // with none uses the implicit `./tsconfig.json` (tsc's own default). + const named = []; + for (let i = 0; i < tokens.length; i++) + if (isProjectFlag(tokens[i]) && tokens[i + 1] && !isFlag(tokens[i + 1])) + named.push(tokens[i + 1]); if (named.length === 0) named.push("tsconfig.json"); for (const project of named) { - if (disabling) neutered.push({ project, flag: disabling[0] }); + if (disabling) neutered.push({ project, flag: disabling }); else projects.push(project); } } From 68d7d6bea9e6cb5d69710ec5e4bd8582c45e4cc9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:18:09 -0400 Subject: [PATCH 19/39] =?UTF-8?q?chore(scripts):=20round-18=20=E2=80=94=20?= =?UTF-8?q?match=20tsc=20by=20basename;=20strip=20quotes=20on=20both=20hal?= =?UTF-8?q?ves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-18 @claude review of #1799: - Finding 1 (regression from round 17): `tokens.includes("tsc")` didn't match a path-invoked binary (`node_modules/.bin/tsc`), which the previous `\btsc\b` regex did — so such a `typecheck` was falsely reported as naming no project. Now matches on the token basename (`isTsc`), keeping round-17's quoting fix while restoring the tolerance. Verified a `node_modules/.bin/tsc` typecheck reports OK. - Nit 2: `rootRunsClientValidate` stripped quotes for the `cd` match but tested `npm run validate` against the unstripped string, so `npm run "validate"` false-failed. Both halves now test the stripped string. - Nits 3 & 4: documented on typecheckProjects — the contrived `--noCheck false` (checking on) is still treated as disabling, and the `&&`/`||`/`;` split runs before tokenizing (a quoted operator inside an arg would split mid-token, unreachable for project paths). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/lib/npm-scripts.mjs | 7 ++++--- scripts/verify-typecheck-coverage.mjs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index 0d0005d4d..e51df1e54 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -68,9 +68,10 @@ export function rootRunsClientValidate(rootScripts, clientDir) { // Quotes are stripped first so `cd "clients/x"` (a valid form) still matches. const escaped = clientDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp(`cd \\.?/?${escaped}(?=$|[\\s&;])`); - return rootReachedCommands(rootScripts).some( - (c) => re.test(c.replace(/["']/g, "")) && /npm run validate/.test(c), - ); + return rootReachedCommands(rootScripts).some((c) => { + const stripped = c.replace(/["']/g, ""); // both halves see quotes stripped + return re.test(stripped) && /npm run validate/.test(stripped); + }); } /** diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index f08299f17..fc5b44164 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -147,21 +147,29 @@ const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); * `--noEmit` passes; web, the only `tsc -b` client, is out of scope): a * **solution-style** `-b` config (`"files": []` + `references`) is run here as * `-p … --listFilesOnly`, which lists nothing — a client adopting it would need - * its `references` expanded to their paths. And the implicit-`./tsconfig.json` + * its `references` expanded to their paths. The implicit-`./tsconfig.json` * fallback assumes **no file operands** — `tsc ` ignores the config and * checks only that file, but would be credited the whole config's file list. + * The `--noCheck`/`--listFilesOnly` detection ignores a following boolean, so + * the contrived explicit `--noCheck false` (checking *on*) is still treated as + * disabling. And the `&&`/`||`/`;` split runs before tokenizing, so a quoted + * operator inside an arg would split mid-token (unreachable — project paths + * carry none of those). */ function typecheckProjects(scripts) { const projects = []; const neutered = []; const isFlag = (t) => t.startsWith("-"); const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); + // Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ + // tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. + const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); for (const name of reachableScripts(scripts, "typecheck")) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; for (const segment of cmd.split(/&&|\|\||;/)) { const tokens = tokenize(segment); - if (!tokens.includes("tsc")) continue; // only tsc commands name projects + if (!tokens.some(isTsc)) continue; // only tsc commands name projects const disabling = tokens.find((t) => /^--(noCheck|listFilesOnly)$/i.test(t), ); From a393bb8e173e88ed7bac3756474c6ebab71f45a6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:39:40 -0400 Subject: [PATCH 20/39] =?UTF-8?q?test:=20round-19=20=E2=80=94=20typecheck?= =?UTF-8?q?=20test-servers'=20unimported=20bin;=20--prefix=20wiring=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-19 @claude review of #1799: - Finding 1: `test-servers/src/server-composable.ts` (a `#!/usr/bin/env node` bin entry nothing imports) got no tsc pass — the barrel alias covers the other 13 test-servers/src files transitively, but not this one, and its own build runs `tsc --noCheck`, so its errors were emitted into `test-servers/build` unchecked. Added it explicitly to `clients/cli/tsconfig.test.json`'s include (zero source changes; verified a bad line in it now fails cli's typecheck). Softened the AGENTS.md clause that implied the whole test-server source is transitively covered. - Nit 2: `rootRunsClientValidate` now also recognizes the `npm --prefix run validate` invocation form (was `cd`-only). - Nit 3 (stderr spy drops the completion-callback overload): left as-is — latent, not live (no exercised path awaits a stderr drain), and adding typed overload params would reintroduce the write-overload complexity the round-1 vi.spyOn refactor removed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- clients/cli/tsconfig.test.json | 11 ++++++++++- scripts/lib/npm-scripts.mjs | 6 ++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e33b56727..f66a7f247 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web`. The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/clients/cli/tsconfig.test.json b/clients/cli/tsconfig.test.json index 6b5dca619..22a45ff2a 100644 --- a/clients/cli/tsconfig.test.json +++ b/clients/cli/tsconfig.test.json @@ -19,6 +19,15 @@ // Only the tests root the project; tsc pulls in the `src` they import. The // src-only tsconfig.json already validates all of `src` (without the // test-only aliases), so listing it here too would just check it twice. - "include": ["__tests__/**/*"], + // + // `server-composable.ts` is listed explicitly because it's a `test-servers` + // bin entry nothing imports — the barrel alias above pulls in the other 13 + // `test-servers/src` files transitively, but not this one, and its own build + // (`test-servers:build`) runs `tsc --noCheck`, so without this line it would + // get no tsc pass at all yet still be emitted into `test-servers/build`. + "include": [ + "__tests__/**/*", + "../../test-servers/src/server-composable.ts" + ], "exclude": ["node_modules", "build"] } diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index e51df1e54..9a4a99ba2 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -66,11 +66,13 @@ export function rootRunsClientValidate(rootScripts, clientDir) { // vs `clients/x-next`) can't satisfy the check for the shorter name — a bare // substring `includes` would let a sibling silently vouch for a dropped client. // Quotes are stripped first so `cd "clients/x"` (a valid form) still matches. + // Both `cd && npm run validate` and `npm --prefix run validate` + // count as running the client. const escaped = clientDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`cd \\.?/?${escaped}(?=$|[\\s&;])`); + const dirRe = new RegExp(`(?:cd|--prefix) \\.?/?${escaped}(?=$|[\\s&;])`); return rootReachedCommands(rootScripts).some((c) => { const stripped = c.replace(/["']/g, ""); // both halves see quotes stripped - return re.test(stripped) && /npm run validate/.test(stripped); + return dirRe.test(stripped) && /\brun validate\b/.test(stripped); }); } From 893a55b869f3c692d008d482d82c4a767f463b56 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 04:57:58 -0400 Subject: [PATCH 21/39] =?UTF-8?q?chore(scripts):=20round-20=20=E2=80=94=20?= =?UTF-8?q?anchor=20validate=20tail;=20guard=20shared-source=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-20 @claude review of #1799: - Finding 1 (false PASS): the wiring matcher's tail `/\brun validate\b/` also matched `run validate:fast` (`:` is a word boundary) — a DIFFERENT script that may skip typecheck. So the root chain could run a client's `validate:fast` while the guard kept measuring its `validate` (unrun), green. Anchored the tail to a boundary (`/\brun validate(?=$|[\s&;])/`). Verified `validate:fast` now hard-fails. - Finding 2: durably close the test-servers/src class instead of point-fixing. Added a SHARED_ROOTS check — every tracked TS under `test-servers/src` + the root `vitest.shared.mts` (the `format:check:shared`/`lint:shared` surface) must land in the GLOBAL union of client projects. So a NEW unimported bin entry is caught (not just server-composable.ts, named in round 19). Verified a fresh `server-orphan-bin.ts` now fails. Zero source changes — the union already covers 15/15. - Nit 4: the integrity message and rootRunsClientValidate docstring now mention the `npm --prefix` form too. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- scripts/lib/npm-scripts.mjs | 26 ++++++++------- scripts/verify-typecheck-coverage.mjs | 47 +++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f66a7f247..8af02fd9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. Beyond the clients it also covers the **shared** first-party TS that no client owns — `test-servers/src/**` and the root `vitest.shared.mts` (the same surface `format:check:shared`/`lint:shared` gate) — requiring each to land in the *global* union of client projects (cli aliases the test-server source), so an unimported `test-servers/src` bin entry can't ship uncompiled-but-unchecked. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/scripts/lib/npm-scripts.mjs b/scripts/lib/npm-scripts.mjs index 9a4a99ba2..fbb616de5 100644 --- a/scripts/lib/npm-scripts.mjs +++ b/scripts/lib/npm-scripts.mjs @@ -55,24 +55,26 @@ function rootReachedCommands(rootScripts) { } /** - * Whether the root `validate` chain invokes `cd && npm run validate`. - * Without this a per-client gate would still be harvested from that client's own - * `validate` and count as coverage even after the root chain stopped running it - * — the "gate silently stops gating" failure, one level up. + * Whether the root `validate` chain runs a client's `validate` — either + * `cd && npm run validate` or `npm --prefix run + * validate`. Without this a per-client gate would still be harvested from that + * client's own `validate` and count as coverage even after the root chain + * stopped running it — the "gate silently stops gating" failure, one level up. */ export function rootRunsClientValidate(rootScripts, clientDir) { - // Anchored match: an optional leading `./` (`cd ./clients/x` genuinely runs - // the client) and a boundary after the dir, so a prefix-sibling (`clients/x` - // vs `clients/x-next`) can't satisfy the check for the shorter name — a bare - // substring `includes` would let a sibling silently vouch for a dropped client. - // Quotes are stripped first so `cd "clients/x"` (a valid form) still matches. - // Both `cd && npm run validate` and `npm --prefix run validate` - // count as running the client. + // Anchored on both ends: an optional leading `./` (`cd ./clients/x` genuinely + // runs the client) and a boundary after the dir, so a prefix-sibling + // (`clients/x` vs `clients/x-next`) can't satisfy the check for the shorter + // name; and a boundary after `validate` so `run validate:fast` (a *different* + // script that may skip typecheck) doesn't count as running `validate`. A bare + // `includes`/`\b` on either would let a sibling silently vouch for a dropped + // client. Quotes are stripped first so `cd "clients/x"` still matches. Both + // `cd && npm run validate` and `npm --prefix run validate` count. const escaped = clientDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const dirRe = new RegExp(`(?:cd|--prefix) \\.?/?${escaped}(?=$|[\\s&;])`); return rootReachedCommands(rootScripts).some((c) => { const stripped = c.replace(/["']/g, ""); // both halves see quotes stripped - return dirRe.test(stripped) && /\brun validate\b/.test(stripped); + return dirRe.test(stripped) && /\brun validate(?=$|[\s&;])/.test(stripped); }); } diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index fc5b44164..e8c11e357 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -270,6 +270,29 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } +// First-party TS that lives outside any client but is still first-party source +// no client "owns" — the same surface `format:check:shared` + `lint:shared` +// gate (#1767). It has no `__tests__`/client project of its own; it must get a +// tsc pass from SOME client project (cli aliases the test-server source, and +// each client's `vitest.config.ts` imports `vitest.shared.mts`). Checked against +// the GLOBAL union of all clients' project files, not per-client — which is why +// a *new* unimported `test-servers/src` bin entry (nothing reaches it) is caught +// here rather than slipping through as `server-composable.ts` once did. +const SHARED_ROOTS = ["test-servers/src", "vitest.shared.mts"]; + +/** Tracked first-party TS under the shared roots (repo-relative POSIX paths). */ +function trackedSharedSource() { + const out = execFileSync("git", ["ls-files", ...SHARED_ROOTS], { + cwd: repoRoot, + encoding: "utf8", + }); + return out + .split("\n") + .filter(Boolean) + .filter(isRequiredSource) + .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); +} + // --------------------------------------------------------------------------- // Phase 1 — gate integrity: is each client's `typecheck` actually run, and does // it actually type-check? Reported (and exited) before the file-coverage pass @@ -305,7 +328,7 @@ for (const clientDir of CLIENTS) { checkingProjects.set(clientDir, []); if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { integrity.push( - `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` — its typecheck isn't invoked by CI.`, + `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` (or \`npm --prefix ${clientDir} run validate\`) — its typecheck isn't invoked by CI.`, ); continue; } @@ -358,10 +381,14 @@ if (integrity.length > 0) { // --------------------------------------------------------------------------- let totalChecked = 0; const failures = []; +const globalCovered = new Set(); for (const clientDir of CLIENTS) { const covered = new Set(); for (const project of checkingProjects.get(clientDir)) - for (const f of projectFiles(clientDir, project)) covered.add(f); + for (const f of projectFiles(clientDir, project)) { + covered.add(f); + globalCovered.add(f); + } const tracked = trackedSourceFiles(clientDir); totalChecked += tracked.length; @@ -369,6 +396,15 @@ for (const clientDir of CLIENTS) { if (!covered.has(f)) failures.push(`${f} — in no tsconfig project`); } +// Shared first-party TS gets no client of its own; require each to land in the +// GLOBAL union of client projects (so a new unimported one is caught, not just +// the file round 19 named explicitly). +const sharedTracked = trackedSharedSource(); +totalChecked += sharedTracked.length; +for (const f of sharedTracked) + if (!globalCovered.has(f)) + failures.push(`${f} — shared source in no client's tsconfig project`); + if (failures.length > 0) { console.error( `verify:typecheck-coverage — ${failures.length} tracked source file(s) get no \`tsc\` pass:\n`, @@ -381,6 +417,10 @@ if (failures.length > 0) { console.error( "For a co-located test, instead move it to `__tests__/` — adding it to the src `include` would make the build emit it.", ); + if (failures.some((f) => f.includes("shared source"))) + console.error( + "For a shared-source file no client imports (e.g. a `test-servers/src` bin entry), name it in a client `tsconfig.test.json`'s `include`, as `server-composable.ts` is.", + ); console.error("See AGENTS.md."); process.exit(1); } @@ -389,6 +429,7 @@ const exemptNote = [...EXEMPT.entries()] .map(([dir, reason]) => `${dir} exempt: ${reason}`) .join("; "); console.log( - `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files across ${CLIENTS.length} clients get a tsc pass` + + `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files ` + + `(${CLIENTS.length} clients + shared) get a tsc pass` + (exemptNote ? ` (${exemptNote}).` : "."), ); From 8087349a937c1733ec328a297045eede1e68dcf7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 05:14:07 -0400 Subject: [PATCH 22/39] =?UTF-8?q?chore(scripts):=20round-21=20=E2=80=94=20?= =?UTF-8?q?deny-by-default=20non-client=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-21 @claude review of #1799 (findings 1 & 2, one fix): - Replaced the `SHARED_ROOTS` allowlist with a deny-by-default non-client set: all tracked TS minus `clients/*` (per-client pass) minus a reasoned `core/` exemption (covered by web's `tsc -b`). This closes BOTH round-21 findings at once: - Finding 1 (stale allowlist fail-open): a rename of `test-servers/src` no longer silently drops it — its files reappear in the required "other" set and hard-fail (verified: `git mv test-servers/src test-servers/source` now fails instead of going 127→113 green). The subtractive `core/` exemption is fail-closed on staleness for the same reason. - Finding 2 (allowlist vs deny-by-default): a new top-level TS location is now auto-required (verified `tools/probe.ts` is flagged), matching the repo-wide reach of the sibling verify-format-coverage. - Nit 3: added the shared-source clause to the README table row (AGENTS got it last round). Header comment updated to describe the non-client coverage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- README.md | 2 +- scripts/verify-typecheck-coverage.mjs | 67 +++++++++++++++++---------- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 093f6c332..6f03c1b80 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (every `clients/*` that declares a `typecheck` script — cli/tui/launcher today, auto-discovered from disk) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project (so a new top-level config/helper can't silently go untypechecked). Also asserts the gate is wired (each `typecheck` 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 (every `clients/*` that declares a `typecheck` script — cli/tui/launcher today, auto-discovered from disk) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires the **shared** first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, and any new non-client, non-`core/` location) to land in some client project's tsc pass. Also asserts the gate is wired (each `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index e8c11e357..d641c24d2 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -15,8 +15,12 @@ // client it runs every project its `typecheck` script names with `tsc // --listFilesOnly` (the accurate measure — it includes import-reached files), // unions the result, and asserts every tracked first-party `.ts`/`.tsx`/`.mts`/ -// `.cts` under the client is in that union. Exits non-zero, listing the -// offenders, on any miss. +// `.cts` under the client is in that union. It also covers, deny-by-default, the +// tracked TS that lives OUTSIDE any client and outside an exempt root (`core/`, +// covered by web's `tsc -b`) — the shared surface (`test-servers/src`, +// `vitest.shared.mts`) plus anything at a new top-level location — requiring +// each to land in the global union of client projects. Exits non-zero, listing +// the offenders, on any miss. // // Like its sibling it also asserts the gate is actually WIRED: each client's // `typecheck` must be reachable from that client's `validate`, and the root @@ -270,27 +274,41 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } -// First-party TS that lives outside any client but is still first-party source -// no client "owns" — the same surface `format:check:shared` + `lint:shared` -// gate (#1767). It has no `__tests__`/client project of its own; it must get a -// tsc pass from SOME client project (cli aliases the test-server source, and -// each client's `vitest.config.ts` imports `vitest.shared.mts`). Checked against -// the GLOBAL union of all clients' project files, not per-client — which is why -// a *new* unimported `test-servers/src` bin entry (nothing reaches it) is caught -// here rather than slipping through as `server-composable.ts` once did. -const SHARED_ROOTS = ["test-servers/src", "vitest.shared.mts"]; +// Roots this guard does NOT require to land in a client project, with the +// reason. `core/` is typechecked by web's `tsc -b` (its projects `include` +// `core/**` by path, so it's covered even without an importer — verified) and +// isn't modeled here. Subtractive, so it's fail-CLOSED on staleness: if `core/` +// were renamed, its files would fall into the required "other" set and be +// flagged, rather than silently dropping out (the failure mode an *allowlist* +// like the old `SHARED_ROOTS` had — see #1799 review round 21). +const EXEMPT_ROOTS = ["core"]; -/** Tracked first-party TS under the shared roots (repo-relative POSIX paths). */ -function trackedSharedSource() { - const out = execFileSync("git", ["ls-files", ...SHARED_ROOTS], { - cwd: repoRoot, - encoding: "utf8", - }); +/** + * Tracked first-party TS that is neither under a `clients/*` dir (covered by the + * per-client pass) nor under an {@link EXEMPT_ROOTS} root — i.e. the shared + * surface no client owns (`test-servers/src/**`, the root `vitest.shared.mts`) + * plus anything new at a fresh top-level location. Deny-by-default: a new such + * file is required without editing this guard, matching the repo-wide reach of + * the sibling `verify-format-coverage`. Each must still get a tsc pass from SOME + * client project — cli aliases the test-server source; each client's + * `vitest.config.ts` imports `vitest.shared.mts` — checked against the GLOBAL + * union of client projects (not per-client), which is why a new unimported + * `test-servers/src` bin entry is caught rather than slipping through as + * `server-composable.ts` once did. + */ +function trackedNonClientSource() { + const out = execFileSync( + "git", + ["ls-files", "*.ts", "*.tsx", "*.mts", "*.cts"], + { cwd: repoRoot, encoding: "utf8" }, + ); return out .split("\n") .filter(Boolean) .filter(isRequiredSource) - .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); + .filter((f) => !f.includes("/build/") && !f.includes("/dist/")) + .filter((f) => !f.startsWith("clients/")) + .filter((f) => !EXEMPT_ROOTS.some((r) => f === r || f.startsWith(`${r}/`))); } // --------------------------------------------------------------------------- @@ -396,12 +414,13 @@ for (const clientDir of CLIENTS) { if (!covered.has(f)) failures.push(`${f} — in no tsconfig project`); } -// Shared first-party TS gets no client of its own; require each to land in the -// GLOBAL union of client projects (so a new unimported one is caught, not just -// the file round 19 named explicitly). -const sharedTracked = trackedSharedSource(); -totalChecked += sharedTracked.length; -for (const f of sharedTracked) +// Non-client first-party TS (shared source, plus anything at a new top-level +// location) gets no client of its own; require each to land in the GLOBAL union +// of client projects, so it's deny-by-default rather than an allowlist that a +// new location could fall outside. +const otherTracked = trackedNonClientSource(); +totalChecked += otherTracked.length; +for (const f of otherTracked) if (!globalCovered.has(f)) failures.push(`${f} — shared source in no client's tsconfig project`); From d700c0873b7861fae25e2f56d0b5e231fed906e3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 05:30:35 -0400 Subject: [PATCH 23/39] =?UTF-8?q?chore(scripts):=20round-22=20=E2=80=94=20?= =?UTF-8?q?narrow=20core/=20exemption=20to=20what=20web's=20tsc=20-b=20cov?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-22 @claude review of #1799: - Finding 1: the `core/` exemption was whole-tree, but web's `tsc -b` only covers `core/**/*.ts` MINUS co-located `*.test.ts(x)` (its app project's include). So a `core/*.tsx`/`*.mts`/`*.cts` or a co-located `core` test got no tsc pass from anything. Narrowed the exemption to exactly that shape (`isExemptCoreFile`): a core `.ts` that isn't a co-located test. So a core `.tsx`/`.mts`/co-located test is now required in some project. Zero-fix today (0 such files); verified `core/mcp/probe-widget.tsx` and a co-located core `.test.ts` are now flagged. - Nit 2: `EXEMPT_ROOTS` is now a Map (dir → reason) and its reason is surfaced in the success line alongside the client exemption. - Nit 3: the non-client miss message is now the general "in no client's tsconfig project"; the `test-servers` remediation clause is conditional on an actual `test-servers/` path (tracked via `nonClientMisses`, not string-sniffing the composed message). - Nit 4: success line says "(N clients + non-client)". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/verify-typecheck-coverage.mjs | 71 +++++++++++++++++---------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index d641c24d2..f5cf99428 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -274,27 +274,40 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } -// Roots this guard does NOT require to land in a client project, with the -// reason. `core/` is typechecked by web's `tsc -b` (its projects `include` -// `core/**` by path, so it's covered even without an importer — verified) and -// isn't modeled here. Subtractive, so it's fail-CLOSED on staleness: if `core/` -// were renamed, its files would fall into the required "other" set and be -// flagged, rather than silently dropping out (the failure mode an *allowlist* -// like the old `SHARED_ROOTS` had — see #1799 review round 21). -const EXEMPT_ROOTS = ["core"]; +// Roots this guard defers to another gate, dir → reason (surfaced in the OK +// line). `core/` is typechecked by web's `tsc -b` — but only the shape web's app +// project actually includes: `../../core/**/*.ts` MINUS co-located +// `*.test.ts(x)` (`isExemptCoreFile` mirrors that). A core `*.tsx`/`*.mts`/`*.cts` +// or a co-located `core` test is NOT covered there and stays required here. The +// exemption is subtractive → fail-CLOSED on staleness: a renamed `core/` (or a +// core file outside web's include) surfaces as required rather than dropping out +// silently (the failure mode the old `SHARED_ROOTS` allowlist had, #1799 r21). +const EXEMPT_ROOTS = new Map([ + [ + "core", + "typechecked by web's `tsc -b` (`core/**/*.ts` minus co-located tests)", + ], +]); + +// A `core/` file web's app project (`clients/web/tsconfig.app.json`) includes: +// `../../core/**/*.ts` and NOT the excluded co-located `*.test.ts`/`*.test.tsx`. +// Only these are safe to exempt from the non-client requirement. +const isExemptCoreFile = (f) => + f.startsWith("core/") && /\.ts$/.test(f) && !/\.test\.tsx?$/.test(f); /** * Tracked first-party TS that is neither under a `clients/*` dir (covered by the - * per-client pass) nor under an {@link EXEMPT_ROOTS} root — i.e. the shared - * surface no client owns (`test-servers/src/**`, the root `vitest.shared.mts`) - * plus anything new at a fresh top-level location. Deny-by-default: a new such - * file is required without editing this guard, matching the repo-wide reach of - * the sibling `verify-format-coverage`. Each must still get a tsc pass from SOME - * client project — cli aliases the test-server source; each client's - * `vitest.config.ts` imports `vitest.shared.mts` — checked against the GLOBAL - * union of client projects (not per-client), which is why a new unimported - * `test-servers/src` bin entry is caught rather than slipping through as - * `server-composable.ts` once did. + * per-client pass) nor exempt (a `core/` file web's `tsc -b` checks — see + * {@link isExemptCoreFile}) — i.e. the shared surface no client owns + * (`test-servers/src`, the root `vitest.shared.mts`), a `core` `*.tsx`/`*.mts`/ + * co-located test web doesn't reach, plus anything new at a fresh top-level + * location. Deny-by-default: a new such file is required without editing this + * guard, matching the repo-wide reach of the sibling `verify-format-coverage`. + * Each must still get a tsc pass from SOME client project — cli aliases the + * test-server source; each client's `vitest.config.ts` imports + * `vitest.shared.mts` — checked against the GLOBAL union of client projects (not + * per-client), which is why a new unimported `test-servers/src` bin entry is + * caught rather than slipping through as `server-composable.ts` once did. */ function trackedNonClientSource() { const out = execFileSync( @@ -308,7 +321,7 @@ function trackedNonClientSource() { .filter(isRequiredSource) .filter((f) => !f.includes("/build/") && !f.includes("/dist/")) .filter((f) => !f.startsWith("clients/")) - .filter((f) => !EXEMPT_ROOTS.some((r) => f === r || f.startsWith(`${r}/`))); + .filter((f) => !isExemptCoreFile(f)); } // --------------------------------------------------------------------------- @@ -420,9 +433,12 @@ for (const clientDir of CLIENTS) { // new location could fall outside. const otherTracked = trackedNonClientSource(); totalChecked += otherTracked.length; +const nonClientMisses = []; for (const f of otherTracked) - if (!globalCovered.has(f)) - failures.push(`${f} — shared source in no client's tsconfig project`); + if (!globalCovered.has(f)) { + nonClientMisses.push(f); + failures.push(`${f} — in no client's tsconfig project`); + } if (failures.length > 0) { console.error( @@ -436,7 +452,7 @@ if (failures.length > 0) { console.error( "For a co-located test, instead move it to `__tests__/` — adding it to the src `include` would make the build emit it.", ); - if (failures.some((f) => f.includes("shared source"))) + if (nonClientMisses.some((f) => f.startsWith("test-servers/"))) console.error( "For a shared-source file no client imports (e.g. a `test-servers/src` bin entry), name it in a client `tsconfig.test.json`'s `include`, as `server-composable.ts` is.", ); @@ -444,11 +460,14 @@ if (failures.length > 0) { process.exit(1); } -const exemptNote = [...EXEMPT.entries()] - .map(([dir, reason]) => `${dir} exempt: ${reason}`) - .join("; "); +const exemptNote = [ + ...[...EXEMPT.entries()].map(([dir, reason]) => `${dir} exempt: ${reason}`), + ...[...EXEMPT_ROOTS.entries()].map( + ([dir, reason]) => `${dir}/ exempt: ${reason}`, + ), +].join("; "); console.log( `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files ` + - `(${CLIENTS.length} clients + shared) get a tsc pass` + + `(${CLIENTS.length} clients + non-client) get a tsc pass` + (exemptNote ? ` (${exemptNote}).` : "."), ); From 9004f8d2650afad4aa8633f28072f26ea807d3da Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 05:46:06 -0400 Subject: [PATCH 24/39] =?UTF-8?q?chore(scripts):=20round-23=20=E2=80=94=20?= =?UTF-8?q?core=20exemption=20mirrors=20both=20web=20projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-23 @claude review of #1799: - Finding 1: round 22's `isExemptCoreFile` mirrored only web's app project, but web's `tsc -b` also builds `tsconfig.test.json`, which includes `core/**/__tests__/**/*.{ts,tsx}`. So a `core/**/__tests__/*.test.ts` (a live layout — `core/mcp/__tests__/fakeInspectorClient.ts` already sits there) was flagged though web checks it. Now mirrors the UNION: under `__tests__/` → exempt `.ts`/`.tsx`; otherwise `.ts` minus co-located `*.test.ts(x)`. Verified a core `__tests__` test now passes while a core `.tsx` outside `__tests__` stays required. - Finding 2: `EXEMPT_ROOTS` was a Map whose keys nothing read (round 22 replaced its filter with hardcoded `isExemptCoreFile`) — a phantom registry. Collapsed to a single `CORE_EXEMPT_REASON` const used in the OK line. - Nit 3: README/AGENTS scope descriptions updated (core is no longer wholesale excluded; the non-client set is deny-by-default). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- README.md | 2 +- scripts/verify-typecheck-coverage.mjs | 40 ++++++++++++--------------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8af02fd9f..552fe3c11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. Beyond the clients it also covers the **shared** first-party TS that no client owns — `test-servers/src/**` and the root `vitest.shared.mts` (the same surface `format:check:shared`/`lint:shared` gate) — requiring each to land in the *global* union of client projects (cli aliases the test-server source), so an unimported `test-servers/src` bin entry can't ship uncompiled-but-unchecked. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` except the `core/` shapes web's `tsc -b` already checks (`core/**/*.ts` + `core/**/__tests__/**`). So `test-servers/src/**`, the root `vitest.shared.mts`, a `core` `*.tsx`/co-located `*.test.ts`, and any new top-level TS location must each land in the *global* union of client projects (cli aliases the test-server source), so e.g. an unimported `test-servers/src` bin entry can't ship uncompiled-but-unchecked. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/README.md b/README.md index 6f03c1b80..3d088e3c4 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (every `clients/*` that declares a `typecheck` script — cli/tui/launcher today, auto-discovered from disk) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires the **shared** first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, and any new non-client, non-`core/` location) to land in some client project's tsc pass. Also asserts the gate is wired (each `typecheck` 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 (every `clients/*` that declares a `typecheck` script — cli/tui/launcher today, auto-discovered from disk) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` 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`, any new top-level location, and the `core/` shapes web's `tsc -b` doesn't cover — a `core` `*.tsx`/`*.mts` or co-located `*.test.ts`) to land in some client project's tsc pass. Also asserts the gate is wired (each `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index f5cf99428..0d093ac66 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -274,26 +274,24 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } -// Roots this guard defers to another gate, dir → reason (surfaced in the OK -// line). `core/` is typechecked by web's `tsc -b` — but only the shape web's app -// project actually includes: `../../core/**/*.ts` MINUS co-located -// `*.test.ts(x)` (`isExemptCoreFile` mirrors that). A core `*.tsx`/`*.mts`/`*.cts` -// or a co-located `core` test is NOT covered there and stays required here. The -// exemption is subtractive → fail-CLOSED on staleness: a renamed `core/` (or a -// core file outside web's include) surfaces as required rather than dropping out -// silently (the failure mode the old `SHARED_ROOTS` allowlist had, #1799 r21). -const EXEMPT_ROOTS = new Map([ - [ - "core", - "typechecked by web's `tsc -b` (`core/**/*.ts` minus co-located tests)", - ], -]); +// `core/` is deferred to web's `tsc -b`; this reason is surfaced in the OK line. +// The exemption is subtractive (a `core/` file web does NOT check falls into the +// required set) → fail-CLOSED on staleness: a renamed `core/`, or a core file +// outside web's include, surfaces as required rather than dropping out silently +// (the failure mode the old `SHARED_ROOTS` allowlist had, #1799 r21/r22). +const CORE_EXEMPT_REASON = + "typechecked by web's `tsc -b` (`core/**/*.ts` + `core/**/__tests__/**`)"; -// A `core/` file web's app project (`clients/web/tsconfig.app.json`) includes: -// `../../core/**/*.ts` and NOT the excluded co-located `*.test.ts`/`*.test.tsx`. -// Only these are safe to exempt from the non-client requirement. -const isExemptCoreFile = (f) => - f.startsWith("core/") && /\.ts$/.test(f) && !/\.test\.tsx?$/.test(f); +// Exempt exactly what web's `tsc -b` covers across its projects — the UNION of +// `tsconfig.app.json` (`../../core/**/*.ts` minus co-located `*.test.ts(x)`) and +// `tsconfig.test.json` (`../../core/**/__tests__/**/*.{ts,tsx}`). A core `*.tsx`/ +// `*.mts`/`*.cts` outside `__tests__`, or a co-located `*.test.ts(x)`, is covered +// by neither and stays required here. +const isExemptCoreFile = (f) => { + if (!f.startsWith("core/")) return false; + if (f.includes("/__tests__/")) return /\.tsx?$/.test(f); + return /\.ts$/.test(f) && !/\.test\.tsx?$/.test(f); +}; /** * Tracked first-party TS that is neither under a `clients/*` dir (covered by the @@ -462,9 +460,7 @@ if (failures.length > 0) { const exemptNote = [ ...[...EXEMPT.entries()].map(([dir, reason]) => `${dir} exempt: ${reason}`), - ...[...EXEMPT_ROOTS.entries()].map( - ([dir, reason]) => `${dir}/ exempt: ${reason}`, - ), + `core/ exempt: ${CORE_EXEMPT_REASON}`, ].join("; "); console.log( `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files ` + From 04f87f6b7e92f119f6a537aafad501e3ff2c59a5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 06:11:30 -0400 Subject: [PATCH 25/39] =?UTF-8?q?chore(scripts):=20round-24=20=E2=80=94=20?= =?UTF-8?q?enroll=20clients/web=20via=20tsconfig=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-24 @claude review of #1799: - Finding 1: `clients/web` was whole-tree EXEMPT, but web's `tsc -b` only reaches what its four referenced projects include — a file at web's package root (e.g. `playwright.config.ts`) got no tsc pass and passed EVERY gate, fully silently. This was the last whole-tree exemption wider than the gate it defers to. Now web is ENROLLED: a client with no `typecheck` script but a `tsconfig.json` `references` array is measured through those reference projects (`tsc -p --listFilesOnly`), and is wired iff its `validate` runs `tsc -b` (`validateRunsTscBuild`). EXEMPT is now the empty escape hatch. Verified: a web-root `playwright.config.ts` with a type error is now flagged, and dropping `tsc -b` from web's build hard-fails the integrity phase. Zero-fix on the tree — the union of web's 4 projects covers all 625 tracked web files. - Nit 2: assert `core/` exists (the OK line unconditionally states its exemption) — parity with the EXEMPT stale-key check. - Nit 3: a `core/` miss now gets core-specific remediation (widen web's `tsconfig.app.json` include, not a cli/tui project). - Nit 4: README/AGENTS scope descriptions corrected — web is enrolled (not exempt), and the exact core-exempt shape (app `*.ts` minus co-located tests + test `__tests__/**/*.{ts,tsx}`). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- README.md | 2 +- scripts/verify-typecheck-coverage.mjs | 141 +++++++++++++++++++++----- 3 files changed, 118 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 552fe3c11..0b87b2d3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` that declares a `typecheck` script; `clients/web` is explicitly exempt, typechecking via `tsc -b`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` except the `core/` shapes web's `tsc -b` already checks (`core/**/*.ts` + `core/**/__tests__/**`). So `test-servers/src/**`, the root `vitest.shared.mts`, a `core` `*.tsx`/co-located `*.test.ts`, and any new top-level TS location must each land in the *global* union of client projects (cli aliases the test-server source), so e.g. an unimported `test-servers/src` bin entry can't ship uncompiled-but-unchecked. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` except the `core/` shapes web's `tsc -b` already checks (its app project's `core/**/*.ts` minus co-located tests, plus its test project's `core/**/__tests__/**/*.{ts,tsx}`). So `test-servers/src/**`, the root `vitest.shared.mts`, a `core` `*.tsx`/`*.mts` outside `__tests__`, a co-located `core` `*.test.ts(x)`, and any new top-level TS location must each land in the *global* union of client projects (cli aliases the test-server source), so e.g. an unimported `test-servers/src` bin entry can't ship uncompiled-but-unchecked. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/README.md b/README.md index 3d088e3c4..ac304fdbe 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (every `clients/*` that declares a `typecheck` script — cli/tui/launcher today, auto-discovered from disk) it runs the tsconfig projects its `typecheck` names with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` 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`, any new top-level location, and the `core/` shapes web's `tsc -b` doesn't cover — a `core` `*.tsx`/`*.mts` or co-located `*.test.ts`) to land in some client project's tsc pass. Also asserts the gate is wired (each `typecheck` 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`, any new top-level location, and the `core/` shapes web's `tsc -b` doesn't cover — a `core` `*.tsx`/`*.mts` outside `__tests__` or a co-located `*.test.ts`) to land in some client project's tsc pass. Also asserts the gate is wired (each `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 0d093ac66..1a15583f3 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node // Durable guard for the "every tracked source file gets a `tsc` pass" invariant -// that #1791 established for the Node clients — every `clients/*` that declares a -// `typecheck` script (cli, tui, launcher today; a new one is picked up from disk -// automatically, see nodeClients). A tsconfig +// that #1791 established for the Node clients — every `clients/*`, enrolled via +// its `typecheck` script's projects (cli, tui, launcher) or, for a `tsc -b` +// client with no `typecheck` script (clients/web), via its `tsconfig.json` +// `references`; a new client is picked up from disk automatically (nodeClients). +// A tsconfig // project only typechecks the files its `include`/`files` name plus whatever // those transitively import — so a new top-level `.ts` (a fresh config file, a // new test helper) can silently fall outside every project and get no @@ -34,7 +36,7 @@ // adding/removing a project is reflected here with no second list to keep in sync. import { execFileSync } from "node:child_process"; -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -53,13 +55,13 @@ const rootPkg = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); -// Clients this guard deliberately does NOT gate, with the reason. `clients/web` -// typechecks via `tsc -b` over a project-reference graph (app/node/storybook/ -// test) this guard can't model. An exemption is explicit so it's a stated -// decision, not an accident of which scripts a manifest happens to declare. -const EXEMPT = new Map([ - ["clients/web", "typechecks via `tsc -b` over project references"], -]); +// Clients this guard deliberately does NOT gate, with the reason. The escape +// hatch for a genuinely-ungateable client. Empty today: `clients/web` used to be +// here (it has no `typecheck` script — its `build` runs `tsc -b`) but is now +// ENROLLED through its `tsconfig.json` `references`, so its package-root files +// are checked rather than whole-tree-exempted (an exemption wider than the gate +// it defers to — #1799 review r24). +const EXEMPT = new Map(); // TypeScript source extensions this guard requires a tsc pass for. Matches its // sibling's TS set (`verify-format-coverage.mjs` — `.mts` is already the idiom @@ -70,17 +72,65 @@ const EXEMPT = new Map([ const isRequiredSource = (rel) => /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); +// Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ +// tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. +const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); + +/** + * The `references` paths in a client's root `tsconfig.json` (a `tsc -b` solution + * config), or `[]` if it has none / no readable tsconfig. These are the projects + * a reference (`tsc -b`) client — one with no `typecheck` script, like + * `clients/web` — is typechecked through, so this guard measures them directly + * instead of exempting the whole tree. + */ +function clientTsconfigReferences(clientDir) { + try { + const raw = readFileSync( + path.join(repoRoot, clientDir, "tsconfig.json"), + "utf8", + ); + // Tolerate JSONC line comments + trailing commas (tsconfig allows both). + const cfg = JSON.parse( + raw.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"), + ); + return Array.isArray(cfg.references) + ? cfg.references.map((r) => r?.path).filter((p) => typeof p === "string") + : []; + } catch { + return []; + } +} + +/** Whether a script reachable from the client's `validate` runs `tsc -b`/`--build`. */ +function validateRunsTscBuild(scripts) { + for (const name of reachableScripts(scripts, "validate")) { + const cmd = scripts?.[name]; + if (typeof cmd !== "string") continue; + for (const segment of cmd.split(/&&|\|\||;/)) { + const tokens = tokenize(segment); + if ( + tokens.some(isTsc) && + tokens.some((t) => t === "-b" || t === "--build") + ) + return true; + } + } + return false; +} + /** * The Node clients this guard covers (#1791): every `clients/` dir with a - * readable `package.json` is required to **either** declare a `typecheck` script - * (→ enrolled) **or** be in {@link EXEMPT} (→ skipped, with a reason). Anything - * else is a gate-integrity failure. Enumerated from **disk** so it's fail-closed - * on every axis: a new client is auto-required (a hardcoded list wouldn't pick - * it up), a client can't be dropped by a root-chain edit (the chain isn't the - * source), renaming the `typecheck` script doesn't silently drop the client (it - * hard-fails — no longer enrolled, not exempt), and a `clients/*` dir holding - * tracked TS but no manifest hard-fails too (rather than being taken for - * "not a client"). Returns `{ clients, problems }`. + * readable `package.json` is required to be **enrolled** — either it declares a + * `typecheck` script (measured through that script's projects) or its root + * `tsconfig.json` has `references` (a `tsc -b` solution like `clients/web`, + * measured through those reference projects) — **or** be in {@link EXEMPT}. + * Anything else is a gate-integrity failure. Enumerated from **disk** so it's + * fail-closed on every axis: a new client is auto-required (a hardcoded list + * wouldn't pick it up), a client can't be dropped by a root-chain edit (the + * chain isn't the source), renaming the `typecheck` script doesn't silently drop + * the client (it hard-fails — no longer enrolled, not exempt), and a `clients/*` + * dir holding tracked TS but no manifest hard-fails too. Returns + * `{ clients, problems }`. */ function nodeClients() { const clientsDir = path.join(repoRoot, "clients"); @@ -110,10 +160,14 @@ function nodeClients() { ); continue; } - if (typeof scripts?.typecheck === "string") clients.push(dir); + if ( + typeof scripts?.typecheck === "string" || + clientTsconfigReferences(dir).length > 0 + ) + clients.push(dir); else problems.push( - `${dir}: declares no \`typecheck\` script and isn't in the EXEMPT set — it gets no tsc pass. Add a \`typecheck\` (or exempt it with a reason).`, + `${dir}: declares no \`typecheck\` script, has no \`tsconfig.json\` \`references\`, and isn't in the EXEMPT set — it gets no tsc pass. Add a \`typecheck\` (or exempt it with a reason).`, ); } // A stale EXEMPT key would otherwise be asserted in the success line while @@ -165,9 +219,6 @@ function typecheckProjects(scripts) { const neutered = []; const isFlag = (t) => t.startsWith("-"); const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); - // Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ - // tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. - const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); for (const name of reachableScripts(scripts, "typecheck")) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; @@ -344,6 +395,16 @@ if (CLIENTS.length === 0 && integrity.length === 0) { process.exit(1); } +// The OK line unconditionally states the `core/` exemption; assert its subject +// exists so the exemption can't outlive it (parity with the EXEMPT stale-key +// check). Subtractive coverage stays fail-closed regardless — a renamed `core/` +// surfaces as required — this only keeps the *note* honest. +if (!existsSync(path.join(repoRoot, "core"))) { + integrity.push( + "`core/` is stated as exempt (CORE_EXEMPT_REASON) but does not exist — update it.", + ); +} + // Vouch for the sibling guard — a guard can't detect being unrun itself, but the // two can each assert the other is still wired into `validate`, so dropping // either is caught here (only deleting both slips through). @@ -364,6 +425,32 @@ for (const clientDir of CLIENTS) { const scripts = JSON.parse( readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), ).scripts; + + // Reference (`tsc -b`) client — no `typecheck` script; measured through its + // `tsconfig.json` `references`, and wired iff its `validate` runs `tsc -b`. + if (typeof scripts?.typecheck !== "string") { + if (!validateRunsTscBuild(scripts)) { + integrity.push( + `${clientDir}: no \`typecheck\` script and its \`validate\` never runs \`tsc -b\` — its \`tsconfig.json\` references are typechecked by nothing.`, + ); + continue; + } + const refs = clientTsconfigReferences(clientDir); + checkingProjects.set( + clientDir, + refs.filter((project) => { + if (projectDisablesChecking(clientDir, project)) { + integrity.push( + `${clientDir}: reference \`${project}\` sets \`noCheck\` in its tsconfig — that project lists files without type-checking them.`, + ); + return false; + } + return true; + }), + ); + continue; + } + if (!reachableScripts(scripts, "validate").has("typecheck")) { integrity.push( `${clientDir}: \`typecheck\` is not reachable from its \`validate\` — the typecheck it measures gates nothing.`, @@ -454,6 +541,10 @@ if (failures.length > 0) { console.error( "For a shared-source file no client imports (e.g. a `test-servers/src` bin entry), name it in a client `tsconfig.test.json`'s `include`, as `server-composable.ts` is.", ); + if (nonClientMisses.some((f) => f.startsWith("core/"))) + console.error( + "For a `core/` file (a `*.tsx`/`*.mts` or co-located test web's `tsc -b` doesn't reach), widen `clients/web/tsconfig.app.json`'s `include` — not a cli/tui project, which shouldn't own `core`.", + ); console.error("See AGENTS.md."); process.exit(1); } From a6a82baf32e32cf988a79fafe956170a24bb957e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 06:40:06 -0400 Subject: [PATCH 26/39] =?UTF-8?q?chore(scripts):=20round-25=20=E2=80=94=20?= =?UTF-8?q?drop=20redundant=20core=20exemption;=20guard=20tsc=20-b=20--noC?= =?UTF-8?q?heck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-25 @claude review of #1799: - Finding 1: enrolling web (round 24) made the hand-maintained `core/` exemption both redundant and fail-open — web's measured `tsc -b` projects already cover core, and the mirror could go stale (narrowing web's include let an unimported core error pass). Deleted `isExemptCoreFile`, `CORE_EXEMPT_REASON`, the core presence assertion, and the exemptNote core clause. `core/` is now required like everything else and covered by measurement (127→894 files; a `core/*.tsx` web doesn't reach is now caught by measurement, not a hand-model). Kept the core-specific remediation clause. - Finding 2: the reference (`tsc -b`) path skipped the non-inertness check — a `tsc -b --noCheck` build gated nothing while the guard stayed green. Turned `validateRunsTscBuild` into `tscBuildStatus` (ok/neutered/none); a neutered `tsc -b` now hard-fails, matching the typecheck-script path. Factored the disabling-flag predicate (`isDisablingFlag`) so both paths share it. - Nit 3: `clientTsconfigReferences` now strips block comments too (every other tsconfig in the repo uses `/* */`). - Nit 5: README/AGENTS wiring clause covers web's `tsc -b` (not just the `typecheck` script), and core is described as measured, not exempt. Nit 4 resolves itself: EXEMPT is empty and the exemptNote/ternary now collapse cleanly. Nit 6 (tsc -b bound to a specific tsconfig) left as recorded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- README.md | 2 +- scripts/verify-typecheck-coverage.mjs | 125 ++++++++++++-------------- 3 files changed, 58 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b87b2d3a..d13630a58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's `typecheck` is reachable from its `validate`, and the root chain runs each client's `validate`), so it can't stay green while measuring a `typecheck` nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` except the `core/` shapes web's `tsc -b` already checks (its app project's `core/**/*.ts` minus co-located tests, plus its test project's `core/**/__tests__/**/*.{ts,tsx}`). So `test-servers/src/**`, the root `vitest.shared.mts`, a `core` `*.tsx`/`*.mts` outside `__tests__`, a co-located `core` `*.test.ts(x)`, and any new top-level TS location must each land in the *global* union of client projects (cli aliases the test-server source), so e.g. an unimported `test-servers/src` bin entry can't ship uncompiled-but-unchecked. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/README.md b/README.md index ac304fdbe..bf9e97d7b 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run 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`, any new top-level location, and the `core/` shapes web's `tsc -b` doesn't cover — a `core` `*.tsx`/`*.mts` outside `__tests__` or a co-located `*.test.ts`) to land in some client project's tsc pass. Also asserts the gate is wired (each `typecheck` 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 ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 1a15583f3..db900c74d 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -18,11 +18,11 @@ // --listFilesOnly` (the accurate measure — it includes import-reached files), // unions the result, and asserts every tracked first-party `.ts`/`.tsx`/`.mts`/ // `.cts` under the client is in that union. It also covers, deny-by-default, the -// tracked TS that lives OUTSIDE any client and outside an exempt root (`core/`, -// covered by web's `tsc -b`) — the shared surface (`test-servers/src`, -// `vitest.shared.mts`) plus anything at a new top-level location — requiring -// each to land in the global union of client projects. Exits non-zero, listing -// the offenders, on any miss. +// tracked TS that lives OUTSIDE any client — the shared surface (`test-servers/ +// src`, `vitest.shared.mts`), all of `core/` (covered by web's enrolled `tsc -b` +// projects), plus anything at a new top-level location — requiring each to land +// in the global union of client projects. Exits non-zero, listing the offenders, +// on any miss. // // Like its sibling it also asserts the gate is actually WIRED: each client's // `typecheck` must be reachable from that client's `validate`, and the root @@ -36,7 +36,7 @@ // adding/removing a project is reflected here with no second list to keep in sync. import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -76,6 +76,11 @@ const isRequiredSource = (rel) => // tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); +// A flag that makes a `tsc` pass list files without type-checking them (so it +// gates nothing). Case-insensitive — tsc's own option parsing is. Shared by the +// `typecheck`-script path and the reference (`tsc -b`) path. +const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); + /** * The `references` paths in a client's root `tsconfig.json` (a `tsc -b` solution * config), or `[]` if it has none / no readable tsconfig. These are the projects @@ -89,9 +94,16 @@ function clientTsconfigReferences(clientDir) { path.join(repoRoot, clientDir, "tsconfig.json"), "utf8", ); - // Tolerate JSONC line comments + trailing commas (tsconfig allows both). + // Tolerate JSONC — block AND line comments + trailing commas (tsconfig + // allows all; block comments are in fact the style of every other tsconfig + // here). Block comments are stripped first so a `//` inside one doesn't + // survive; a `//` inside a string value (e.g. an `https://` URL) is a + // theoretical false strip this guard's tsconfigs never hit. const cfg = JSON.parse( - raw.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"), + raw + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .replace(/,(\s*[}\]])/g, "$1"), ); return Array.isArray(cfg.references) ? cfg.references.map((r) => r?.path).filter((p) => typeof p === "string") @@ -101,21 +113,26 @@ function clientTsconfigReferences(clientDir) { } } -/** Whether a script reachable from the client's `validate` runs `tsc -b`/`--build`. */ -function validateRunsTscBuild(scripts) { +/** + * How the client's `validate` runs `tsc -b` (the pass that typechecks a + * reference client): `"ok"` (a real `tsc -b`), `"neutered"` (a `tsc -b` carrying + * `--noCheck`/`--listFilesOnly` — lists files but checks nothing, the same hole + * the `typecheck`-script path rejects), or `"none"` (no `tsc -b` at all). + */ +function tscBuildStatus(scripts) { + let status = "none"; for (const name of reachableScripts(scripts, "validate")) { const cmd = scripts?.[name]; if (typeof cmd !== "string") continue; for (const segment of cmd.split(/&&|\|\||;/)) { const tokens = tokenize(segment); - if ( - tokens.some(isTsc) && - tokens.some((t) => t === "-b" || t === "--build") - ) - return true; + if (!tokens.some(isTsc)) continue; + if (!tokens.some((t) => t === "-b" || t === "--build")) continue; + if (tokens.some(isDisablingFlag)) status = "neutered"; + else return "ok"; // a real, checking `tsc -b` } } - return false; + return status; } /** @@ -225,9 +242,7 @@ function typecheckProjects(scripts) { for (const segment of cmd.split(/&&|\|\||;/)) { const tokens = tokenize(segment); if (!tokens.some(isTsc)) continue; // only tsc commands name projects - const disabling = tokens.find((t) => - /^--(noCheck|listFilesOnly)$/i.test(t), - ); + const disabling = tokens.find(isDisablingFlag); // A project path follows `-p`/`--project`/`-b`/`--build`; a tsc command // with none uses the implicit `./tsconfig.json` (tsc's own default). const named = []; @@ -325,38 +340,18 @@ function trackedSourceFiles(clientDir) { .filter((f) => !f.includes("/build/") && !f.includes("/dist/")); } -// `core/` is deferred to web's `tsc -b`; this reason is surfaced in the OK line. -// The exemption is subtractive (a `core/` file web does NOT check falls into the -// required set) → fail-CLOSED on staleness: a renamed `core/`, or a core file -// outside web's include, surfaces as required rather than dropping out silently -// (the failure mode the old `SHARED_ROOTS` allowlist had, #1799 r21/r22). -const CORE_EXEMPT_REASON = - "typechecked by web's `tsc -b` (`core/**/*.ts` + `core/**/__tests__/**`)"; - -// Exempt exactly what web's `tsc -b` covers across its projects — the UNION of -// `tsconfig.app.json` (`../../core/**/*.ts` minus co-located `*.test.ts(x)`) and -// `tsconfig.test.json` (`../../core/**/__tests__/**/*.{ts,tsx}`). A core `*.tsx`/ -// `*.mts`/`*.cts` outside `__tests__`, or a co-located `*.test.ts(x)`, is covered -// by neither and stays required here. -const isExemptCoreFile = (f) => { - if (!f.startsWith("core/")) return false; - if (f.includes("/__tests__/")) return /\.tsx?$/.test(f); - return /\.ts$/.test(f) && !/\.test\.tsx?$/.test(f); -}; - /** - * Tracked first-party TS that is neither under a `clients/*` dir (covered by the - * per-client pass) nor exempt (a `core/` file web's `tsc -b` checks — see - * {@link isExemptCoreFile}) — i.e. the shared surface no client owns - * (`test-servers/src`, the root `vitest.shared.mts`), a `core` `*.tsx`/`*.mts`/ - * co-located test web doesn't reach, plus anything new at a fresh top-level - * location. Deny-by-default: a new such file is required without editing this - * guard, matching the repo-wide reach of the sibling `verify-format-coverage`. - * Each must still get a tsc pass from SOME client project — cli aliases the - * test-server source; each client's `vitest.config.ts` imports - * `vitest.shared.mts` — checked against the GLOBAL union of client projects (not - * per-client), which is why a new unimported `test-servers/src` bin entry is - * caught rather than slipping through as `server-composable.ts` once did. + * Tracked first-party TS that is not under a `clients/*` dir (which the + * per-client pass covers) — the shared surface no client owns (`test-servers/src`, + * the root `vitest.shared.mts`), all of `core/` (which web's enrolled `tsc -b` + * projects cover — measured, not hand-modeled), plus anything new at a fresh + * top-level location. Deny-by-default: a new such file is required without + * editing this guard, matching the repo-wide reach of the sibling + * `verify-format-coverage`. Each must get a tsc pass from SOME client project + * (cli aliases the test-server source; web's app/test projects include `core/`; + * each client's `vitest.config.ts` imports `vitest.shared.mts`) — checked against + * the GLOBAL union of client projects, which is why a new unimported + * `test-servers/src` bin entry or a `core` `*.tsx` web doesn't reach is caught. */ function trackedNonClientSource() { const out = execFileSync( @@ -369,8 +364,7 @@ function trackedNonClientSource() { .filter(Boolean) .filter(isRequiredSource) .filter((f) => !f.includes("/build/") && !f.includes("/dist/")) - .filter((f) => !f.startsWith("clients/")) - .filter((f) => !isExemptCoreFile(f)); + .filter((f) => !f.startsWith("clients/")); } // --------------------------------------------------------------------------- @@ -395,16 +389,6 @@ if (CLIENTS.length === 0 && integrity.length === 0) { process.exit(1); } -// The OK line unconditionally states the `core/` exemption; assert its subject -// exists so the exemption can't outlive it (parity with the EXEMPT stale-key -// check). Subtractive coverage stays fail-closed regardless — a renamed `core/` -// surfaces as required — this only keeps the *note* honest. -if (!existsSync(path.join(repoRoot, "core"))) { - integrity.push( - "`core/` is stated as exempt (CORE_EXEMPT_REASON) but does not exist — update it.", - ); -} - // Vouch for the sibling guard — a guard can't detect being unrun itself, but the // two can each assert the other is still wired into `validate`, so dropping // either is caught here (only deleting both slips through). @@ -427,11 +411,15 @@ for (const clientDir of CLIENTS) { ).scripts; // Reference (`tsc -b`) client — no `typecheck` script; measured through its - // `tsconfig.json` `references`, and wired iff its `validate` runs `tsc -b`. + // `tsconfig.json` `references`, and wired iff its `validate` runs a real + // (checking) `tsc -b`. if (typeof scripts?.typecheck !== "string") { - if (!validateRunsTscBuild(scripts)) { + const status = tscBuildStatus(scripts); + if (status !== "ok") { integrity.push( - `${clientDir}: no \`typecheck\` script and its \`validate\` never runs \`tsc -b\` — its \`tsconfig.json\` references are typechecked by nothing.`, + status === "neutered" + ? `${clientDir}: its \`validate\`'s \`tsc -b\` carries \`--noCheck\`/\`--listFilesOnly\` — it lists files without type-checking them, so its references are gated by nothing.` + : `${clientDir}: no \`typecheck\` script and its \`validate\` never runs \`tsc -b\` — its \`tsconfig.json\` references are typechecked by nothing.`, ); continue; } @@ -549,10 +537,9 @@ if (failures.length > 0) { process.exit(1); } -const exemptNote = [ - ...[...EXEMPT.entries()].map(([dir, reason]) => `${dir} exempt: ${reason}`), - `core/ exempt: ${CORE_EXEMPT_REASON}`, -].join("; "); +const exemptNote = [...EXEMPT.entries()] + .map(([dir, reason]) => `${dir} exempt: ${reason}`) + .join("; "); console.log( `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files ` + `(${CLIENTS.length} clients + non-client) get a tsc pass` + From be8ca3baa1858b77397d672c7dfdee34a018e06a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 06:57:54 -0400 Subject: [PATCH 27/39] =?UTF-8?q?chore(scripts):=20round-26=20=E2=80=94=20?= =?UTF-8?q?expand=20solution=20configs=20wherever=20harvested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-26 @claude review of #1799: - Finding 1: the reference (`tsc -b`) path was selected only by "no typecheck script", so adding `typecheck: "tsc -b"` to web (the natural uniformity move) flipped it to the typecheck-script path, where the harvested solution config lists nothing → 665 false failures. Fixed at the measurement layer: projectFiles now expands a solution config (`{"files":[], "references":[…]}` — lists nothing under --listFilesOnly) to its references and measures those recursively, so a `tsc -b` project is measured no matter which path harvests it. Generalized the references reader (tsconfigReferences(path)). Verified web with `typecheck: "tsc -b"` now passes. Dropped the stale "web … is out of scope" JSDoc clause. - Nit 2: a `core/**/__tests__/**` miss now points at `tsconfig.test.json` (its actual owner), not `tsconfig.app.json`. - Nit 4: AGENTS.md's one-line summary now mentions the non-client (`core/`, `test-servers/src`) coverage, matching the detailed bullet. Nit 3 (EXEMPT empty, dormant stale-key check) left as the intended escape hatch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- scripts/verify-typecheck-coverage.mjs | 74 ++++++++++++++++++--------- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d13630a58..afa470fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,7 +229,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index db900c74d..28ed90ab3 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -82,18 +82,13 @@ const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); /** - * The `references` paths in a client's root `tsconfig.json` (a `tsc -b` solution - * config), or `[]` if it has none / no readable tsconfig. These are the projects - * a reference (`tsc -b`) client — one with no `typecheck` script, like - * `clients/web` — is typechecked through, so this guard measures them directly - * instead of exempting the whole tree. + * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` + * (a `tsc -b` solution config), or `[]` if it has none / isn't readable. Paths + * are as written (relative to that tsconfig's own directory). */ -function clientTsconfigReferences(clientDir) { +function tsconfigReferences(tsconfigRel) { try { - const raw = readFileSync( - path.join(repoRoot, clientDir, "tsconfig.json"), - "utf8", - ); + const raw = readFileSync(path.join(repoRoot, tsconfigRel), "utf8"); // Tolerate JSONC — block AND line comments + trailing commas (tsconfig // allows all; block comments are in fact the style of every other tsconfig // here). Block comments are stripped first so a `//` inside one doesn't @@ -113,6 +108,15 @@ function clientTsconfigReferences(clientDir) { } } +/** + * The `references` in a client's root `tsconfig.json`. Non-empty for a `tsc -b` + * client (like `clients/web`, which has no `typecheck` script) — this guard + * enrolls it through these instead of exempting the whole tree. + */ +function clientTsconfigReferences(clientDir) { + return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json")); +} + /** * How the client's `validate` runs `tsc -b` (the pass that typechecks a * reference client): `"ok"` (a real `tsc -b`), `"neutered"` (a `tsc -b` carrying @@ -218,18 +222,19 @@ const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); * checking nothing. The config-file form (`noCheck` set in the tsconfig) is * caught separately by {@link projectDisablesChecking}. * - * Two limitations, both unreachable today (cli/tui/launcher use plain `-p` - * `--noEmit` passes; web, the only `tsc -b` client, is out of scope): a - * **solution-style** `-b` config (`"files": []` + `references`) is run here as - * `-p … --listFilesOnly`, which lists nothing — a client adopting it would need - * its `references` expanded to their paths. The implicit-`./tsconfig.json` - * fallback assumes **no file operands** — `tsc ` ignores the config and - * checks only that file, but would be credited the whole config's file list. - * The `--noCheck`/`--listFilesOnly` detection ignores a following boolean, so - * the contrived explicit `--noCheck false` (checking *on*) is still treated as - * disabling. And the `&&`/`||`/`;` split runs before tokenizing, so a quoted - * operator inside an arg would split mid-token (unreachable — project paths - * carry none of those). + * A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists + * nothing itself; {@link projectFiles} expands it to its references, so this + * form is measured whether it reaches here (a `typecheck: "tsc -b"`) or the + * dedicated reference path (`clients/web`, which declares no `typecheck`). + * + * Minor limitations, all unreachable with the plain `-p --noEmit` passes here: + * the implicit-`./tsconfig.json` fallback assumes **no file operands** (`tsc + * ` ignores the config and checks only that file, but would be credited + * the whole config's file list); the `--noCheck`/`--listFilesOnly` detection + * ignores a following boolean, so the contrived explicit `--noCheck false` + * (checking *on*) is still treated as disabling; and the `&&`/`||`/`;` split + * runs before tokenizing, so a quoted operator inside an arg would split + * mid-token (project paths carry none of those). */ function typecheckProjects(scripts) { const projects = []; @@ -283,9 +288,13 @@ function projectDisablesChecking(clientDir, project) { * Repo-relative POSIX paths of the files a project typechecks. Absolute paths * outside the repo root (lib.d.ts) and anything under `node_modules` are * dropped; the aliased `core/` + `test-servers/` sources stay in the set but - * are harmless — the set is only ever queried with client-relative paths. + * are harmless — the set is only ever queried with client-relative paths. A + * **solution config** (`{"files": [], "references": […]}`) lists nothing under + * `--listFilesOnly`, so it's expanded to its references and those measured + * (recursively) — this is what makes a `tsc -b` project measurable no matter + * which enrollment path harvests it (`typecheck` script or the reference path). */ -function projectFiles(clientDir, project) { +function projectFiles(clientDir, project, seen = new Set()) { const absClient = path.join(repoRoot, clientDir); let stdout; try { @@ -322,6 +331,21 @@ function projectFiles(clientDir, project) { if (rel.startsWith("..") || rel.includes("node_modules")) continue; covered.add(rel.split(path.sep).join("/")); } + // A solution config lists nothing — expand it to its references (relative to + // the solution's own dir) and measure those, so a `tsc -b` project counts. + if (covered.size === 0) { + const projectRel = path.posix.join(clientDir, project); + const projectDir = path.posix.dirname(projectRel); + for (const ref of tsconfigReferences(projectRel)) { + const refProject = path.posix.relative( + clientDir, + path.posix.join(projectDir, ref), + ); + if (seen.has(refProject)) continue; + seen.add(refProject); + for (const f of projectFiles(clientDir, refProject, seen)) covered.add(f); + } + } return covered; } @@ -531,7 +555,7 @@ if (failures.length > 0) { ); if (nonClientMisses.some((f) => f.startsWith("core/"))) console.error( - "For a `core/` file (a `*.tsx`/`*.mts` or co-located test web's `tsc -b` doesn't reach), widen `clients/web/tsconfig.app.json`'s `include` — not a cli/tui project, which shouldn't own `core`.", + "For a `core/` file web's `tsc -b` doesn't reach, widen the web project that owns it — `tsconfig.test.json` for `core/**/__tests__/**`, else `tsconfig.app.json` — not a cli/tui project, which shouldn't own `core`.", ); console.error("See AGENTS.md."); process.exit(1); From 82db89e62cf8abd26afdec22053667786ca57042 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 07:18:21 -0400 Subject: [PATCH 28/39] =?UTF-8?q?chore(scripts):=20round-27=20=E2=80=94=20?= =?UTF-8?q?non-inertness=20follows=20references=20on=20both=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-27 @claude review of #1799: - Finding 1: round 26 made COVERAGE follow a solution config's references, but the non-inertness (`noCheck`) check on the typecheck-script path still only inspected the harvested solution (empty compilerOptions) — so a `noCheck` in a REFERENCED project was invisible there (caught only on the reference path). Factored a shared `resolveLeafProjects` (a solution → its real leaf projects, cached rawProjectFiles) used by BOTH coverage (`projectFiles`) and a shared `checkingLeaves` disable-check, so a `noCheck` in a referenced project is now caught on either path. Verified: `typecheck: "tsc -b"` + `noCheck` in tsconfig.app.json now hard-fails (was green). Caching also cut the guard from ~20s to ~11s. - Nit 2: directory-form references (`{ "path": "./packages/a" }`) now resolve to `/tsconfig.json`, so a nested-solution dir reference is expanded. - Nit 3: reworded the empty-listFiles comment (it's "solution config OR errored config", both of which the reference expansion handles). - Nit 4: script header notes a `tsc -b` solution is reduced to its references on either enrollment path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- scripts/verify-typecheck-coverage.mjs | 133 +++++++++++++++++--------- 1 file changed, 86 insertions(+), 47 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 28ed90ab3..7bf6687d6 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node // Durable guard for the "every tracked source file gets a `tsc` pass" invariant // that #1791 established for the Node clients — every `clients/*`, enrolled via -// its `typecheck` script's projects (cli, tui, launcher) or, for a `tsc -b` -// client with no `typecheck` script (clients/web), via its `tsconfig.json` -// `references`; a new client is picked up from disk automatically (nodeClients). +// its `typecheck` script's projects (cli, tui, launcher) or, for a client with +// no `typecheck` script (clients/web), via its `tsconfig.json` `references`; a +// new client is picked up from disk automatically (nodeClients). Either way a +// `tsc -b` solution config is reduced to the real projects it references +// (resolveLeafProjects), so coverage and the non-inertness check both follow it. // A tsconfig // project only typechecks the files its `include`/`files` name plus whatever // those transitively import — so a new top-level `.ts` (a fresh config file, a @@ -285,16 +287,17 @@ function projectDisablesChecking(clientDir, project) { } /** - * Repo-relative POSIX paths of the files a project typechecks. Absolute paths - * outside the repo root (lib.d.ts) and anything under `node_modules` are - * dropped; the aliased `core/` + `test-servers/` sources stay in the set but - * are harmless — the set is only ever queried with client-relative paths. A - * **solution config** (`{"files": [], "references": […]}`) lists nothing under - * `--listFilesOnly`, so it's expanded to its references and those measured - * (recursively) — this is what makes a `tsc -b` project measurable no matter - * which enrollment path harvests it (`typecheck` script or the reference path). + * Repo-relative POSIX paths of the files ONE project (no reference expansion) + * typechecks. Absolute paths outside the repo root (lib.d.ts) and anything under + * `node_modules` are dropped; the aliased `core/` + `test-servers/` sources stay + * in the set but are harmless — the set is only ever queried with client-relative + * paths. Cached: `resolveLeafProjects` and `projectFiles` both list a project. */ -function projectFiles(clientDir, project, seen = new Set()) { +const rawFilesCache = new Map(); +function rawProjectFiles(clientDir, project) { + const key = `${clientDir}|${project}`; + const cached = rawFilesCache.get(key); + if (cached) return cached; const absClient = path.join(repoRoot, clientDir); let stdout; try { @@ -331,24 +334,73 @@ function projectFiles(clientDir, project, seen = new Set()) { if (rel.startsWith("..") || rel.includes("node_modules")) continue; covered.add(rel.split(path.sep).join("/")); } - // A solution config lists nothing — expand it to its references (relative to - // the solution's own dir) and measure those, so a `tsc -b` project counts. - if (covered.size === 0) { - const projectRel = path.posix.join(clientDir, project); - const projectDir = path.posix.dirname(projectRel); - for (const ref of tsconfigReferences(projectRel)) { - const refProject = path.posix.relative( - clientDir, - path.posix.join(projectDir, ref), - ); - if (seen.has(refProject)) continue; - seen.add(refProject); - for (const f of projectFiles(clientDir, refProject, seen)) covered.add(f); - } - } + rawFilesCache.set(key, covered); return covered; } +/** + * The leaf tsconfig projects `project` resolves to (paths relative to + * `clientDir`): itself if it lists files (or has no `references`), else its + * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": + * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how + * it's reduced to the real projects — and doing it here (not just inside + * `projectFiles`) is what lets BOTH coverage and the non-inertness check follow + * the same graph, so a `noCheck` in a *referenced* project is caught no matter + * which enrollment path harvested the solution. + */ +function resolveLeafProjects(clientDir, project, seen = new Set()) { + if (seen.has(project)) return []; + seen.add(project); + // Lists files → a real leaf. (An empty set is a solution config, or a config + // that errored — either way the reference expansion below is the right next + // step: a broken config yields no references too.) + if (rawProjectFiles(clientDir, project).size > 0) return [project]; + // The config file `project` resolves to — a directory-form reference + // (`{ "path": "./packages/a" }`) means `/tsconfig.json`. + const projectRel = path.posix.join(clientDir, project); + const configFile = projectRel.endsWith(".json") + ? projectRel + : path.posix.join(projectRel, "tsconfig.json"); + const refs = tsconfigReferences(configFile); + if (refs.length === 0) return [project]; // no files, no refs — itself + const configDir = path.posix.dirname(configFile); + return refs.flatMap((ref) => + resolveLeafProjects( + clientDir, + path.posix.relative(clientDir, path.posix.join(configDir, ref)), + seen, + ), + ); +} + +/** Repo-relative files a project covers, following a solution config's references. */ +function projectFiles(clientDir, project) { + const covered = new Set(); + for (const leaf of resolveLeafProjects(clientDir, project)) + for (const f of rawProjectFiles(clientDir, leaf)) covered.add(f); + return covered; +} + +/** + * The leaf projects (across `projects`, solutions expanded) that genuinely + * type-check — i.e. minus any whose tsconfig sets `noCheck`, each of which is + * pushed to `integrity`. Shared by both enrollment paths so a `noCheck` in a + * referenced project is caught whether the solution was harvested from a + * `typecheck` script or is the reference-path client's own `tsconfig.json`. + */ +function checkingLeaves(clientDir, projects, integrity) { + const checking = []; + for (const project of projects) + for (const leaf of resolveLeafProjects(clientDir, project)) { + if (projectDisablesChecking(clientDir, leaf)) + integrity.push( + `${clientDir}: \`${leaf}\` sets \`noCheck\` in its tsconfig — that project lists files without type-checking them, so it gates nothing.`, + ); + else if (!checking.includes(leaf)) checking.push(leaf); + } + return checking; +} + /** Tracked first-party TS (`.ts`/`.tsx`/`.mts`/`.cts`) under a client (excludes build output). */ function trackedSourceFiles(clientDir) { const out = execFileSync( @@ -447,18 +499,9 @@ for (const clientDir of CLIENTS) { ); continue; } - const refs = clientTsconfigReferences(clientDir); checkingProjects.set( clientDir, - refs.filter((project) => { - if (projectDisablesChecking(clientDir, project)) { - integrity.push( - `${clientDir}: reference \`${project}\` sets \`noCheck\` in its tsconfig — that project lists files without type-checking them.`, - ); - return false; - } - return true; - }), + checkingLeaves(clientDir, clientTsconfigReferences(clientDir), integrity), ); continue; } @@ -474,15 +517,6 @@ for (const clientDir of CLIENTS) { integrity.push( `${clientDir}: its \`typecheck\` runs \`-p ${project}\` with \`${flag}\` — that pass lists files without type-checking them, so it gates nothing.`, ); - const checking = projects.filter((project) => { - if (projectDisablesChecking(clientDir, project)) { - integrity.push( - `${clientDir}: \`-p ${project}\` sets \`noCheck\` in its tsconfig — that pass lists files without type-checking them, so it gates nothing.`, - ); - return false; - } - return true; - }); // Fire only when nothing was harvested at all — not when projects WERE named // but every one is neutered (command flag) or config-disabled (`projects` was // non-empty in that case; those get their own lines above). @@ -490,7 +524,12 @@ for (const clientDir of CLIENTS) { integrity.push( `${clientDir}: its \`typecheck\` names no \`-p \` — nothing is typechecked.`, ); - checkingProjects.set(clientDir, checking); + // Resolve each harvested project to its leaves (a `tsc -b` solution expands), + // and disable-check each leaf — so a `noCheck` in a referenced project counts. + checkingProjects.set( + clientDir, + checkingLeaves(clientDir, projects, integrity), + ); } if (integrity.length > 0) { From be22b28539359716599b281ba7809e7189ce6336 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:04:42 -0400 Subject: [PATCH 29/39] =?UTF-8?q?chore(scripts):=20round-28=20=E2=80=94=20?= =?UTF-8?q?dedup=20shared=20leaf=20in=20checkingLeaves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-28 @claude review of #1799 (which found no new gate hole): - Finding 1: `checkingLeaves` dedup'd the `checking` array but ran projectDisablesChecking + integrity.push on every visit, so a leaf reachable from two harvested projects produced a duplicate `noCheck` line (and a redundant `tsc --showConfig`). Now tracks visited leaves in a Set and skips an already-seen leaf entirely. Verified a duplicate harvested project yields one line, not two. - Nit 3 (record-completeness): noted in AGENTS that the one tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by `@typescript-eslint/ban-ts-comment` across lint:core/lint:shared/each client. Nit 2 (the ~11s perf claim isn't reproducible on a CI-class runner) and nit 4 (hybrid-solution / composite boundaries, both tsc-rejected → unreachable) left as recorded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- scripts/verify-typecheck-coverage.mjs | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afa470fa5..d7bde62bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 7bf6687d6..272d226f3 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -390,13 +390,19 @@ function projectFiles(clientDir, project) { */ function checkingLeaves(clientDir, projects, integrity) { const checking = []; + // Dedup across all harvested projects (a leaf can be reached from two of them + // — `resolveLeafProjects` only dedups within one) so a shared leaf isn't + // reported twice, nor `--showConfig`-spawned twice. + const seen = new Set(); for (const project of projects) for (const leaf of resolveLeafProjects(clientDir, project)) { + if (seen.has(leaf)) continue; + seen.add(leaf); if (projectDisablesChecking(clientDir, leaf)) integrity.push( `${clientDir}: \`${leaf}\` sets \`noCheck\` in its tsconfig — that project lists files without type-checking them, so it gates nothing.`, ); - else if (!checking.includes(leaf)) checking.push(leaf); + else checking.push(leaf); } return checking; } From 0a415d106a51cf96b1da8314e088228e5c99e95f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:26:56 -0400 Subject: [PATCH 30/39] test(scripts): unit-test the typecheck-coverage guard's parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-29 @claude review of #1799 (no new gate hole; the finding is that the guard itself has no tests): - Made the guard's pure helpers importable: exported `typecheckProjects`, `tscBuildStatus`, `tsconfigReferences`, `clientTsconfigReferences`, `resolveLeafProjects`, `isTsc`, `isDisablingFlag`, `isRequiredSource`, and moved the module-init + phase execution behind an exported `main()` run only when the file is executed directly (importing it for tests no longer runs the guard). Behavior when run directly is unchanged (894 files). - Added table-driven tests (node's built-in `node --test`; the root has no vitest harness by design) for the shared lib (`tokenize`, `reachableScripts` incl. pre/post hooks, `rootRunsClientValidate` across cd/--prefix/quoted/ prefix-sibling/`validate:fast`, `rootReachesScript`) and the guard's parsers (`isRequiredSource`, `isTsc`, `isDisablingFlag`, `typecheckProjects`, `tscBuildStatus`) — one case per rule a review round found (r7/r10/r13/r15/ r16/r17/r18/r19/r20/r25). 12 tests. Wired `test:scripts` into `validate`. - Nit 2: a `scripts/*.ts` miss now gets a scripts-specific remediation (keep it `.mjs`, or give `scripts/` its own tsconfig project). - Documented the guard's test suite in AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- package.json | 3 +- scripts/lib/npm-scripts.test.mjs | 98 +++++++ scripts/verify-typecheck-coverage.mjs | 323 +++++++++++---------- scripts/verify-typecheck-coverage.test.mjs | 99 +++++++ 5 files changed, 371 insertions(+), 154 deletions(-) create mode 100644 scripts/lib/npm-scripts.test.mjs create mode 100644 scripts/verify-typecheck-coverage.test.mjs diff --git a/AGENTS.md b/AGENTS.md index d7bde62bd..c082bcbc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode. - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/package.json b/package.json index ea374d780..45ed8a9eb 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", - "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "test:scripts": "node --test \"scripts/**/*.test.mjs\"", + "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.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}\"", diff --git a/scripts/lib/npm-scripts.test.mjs b/scripts/lib/npm-scripts.test.mjs new file mode 100644 index 000000000..07e49d3a6 --- /dev/null +++ b/scripts/lib/npm-scripts.test.mjs @@ -0,0 +1,98 @@ +// Table-driven tests for the shared npm-script reachability helpers. Each case +// pins a rule the #1799 review took a round to get right — the comment names it. +// Run via `npm run test:scripts` (node:test; the root has no vitest harness). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + reachableScripts, + rootReachesScript, + rootRunsClientValidate, + tokenize, +} from "./npm-scripts.mjs"; + +test("tokenize: quoting (r17)", () => { + const cases = [ + [ + "tsc --noEmit -p tsconfig.json", + ["tsc", "--noEmit", "-p", "tsconfig.json"], + ], + [`tsc -p "tsconfig.test.json"`, ["tsc", "-p", "tsconfig.test.json"]], + [`tsc -p 'tsconfig.test.json'`, ["tsc", "-p", "tsconfig.test.json"]], + [ + `prettier --check "core/**/*.ts"`, + ["prettier", "--check", "core/**/*.ts"], + ], + [" spaced out ", ["spaced", "out"]], + ["", []], + ]; + for (const [input, expected] of cases) + assert.deepEqual(tokenize(input), expected, input); +}); + +test("reachableScripts: follows `npm run` refs and pre/post hooks (r10)", () => { + const scripts = { + validate: "npm run lint && npm run build", + lint: "eslint .", + build: "tsc", + prebuild: "echo pre", // implicit hook npm runs around `build` + postbuild: "echo post", + unrelated: "noop", + }; + const reached = reachableScripts(scripts, "validate"); + assert.ok(reached.has("validate")); + assert.ok(reached.has("lint")); + assert.ok(reached.has("build")); + assert.ok(reached.has("prebuild"), "pre hook"); + assert.ok(reached.has("postbuild"), "post hook"); + assert.ok(!reached.has("unrelated")); +}); + +test("reachableScripts: a `prevalidate`-hosted typecheck is reachable (r10)", () => { + const scripts = { + validate: "npm run build && npm run test", + prevalidate: "npm run typecheck", + typecheck: "tsc --noEmit", + build: "tsc", + test: "vitest run", + }; + assert.ok(reachableScripts(scripts, "validate").has("typecheck")); +}); + +test("rootRunsClientValidate: forms that count vs. don't", () => { + const runs = (cmd) => + rootRunsClientValidate({ validate: cmd, x: cmd }, "clients/tui"); + // Counts: + assert.ok(runs("cd clients/tui && npm run validate"), "plain cd"); + assert.ok(runs("cd ./clients/tui && npm run validate"), "leading ./ (r15)"); + assert.ok(runs(`cd "clients/tui" && npm run validate`), "quoted dir (r17)"); + assert.ok( + runs(`cd clients/tui && npm run "validate"`), + "quoted script name (r18)", + ); + assert.ok( + runs("cd clients/tui && npm run build && npm run validate"), + "extra step", + ); + assert.ok(runs("npm --prefix clients/tui run validate"), "--prefix (r19)"); + // Does NOT count: + assert.ok( + !runs("cd clients/tui-next && npm run validate"), + "prefix-sibling must not match the shorter name (r16)", + ); + assert.ok( + !runs("cd clients/tui && npm run validate:fast"), + "`run validate:fast` is a different script (r20)", + ); + assert.ok(!runs("cd clients/tui && npm run build"), "no validate"); +}); + +test("rootReachesScript: sibling-guard vouching", () => { + const scripts = { + validate: "npm run verify:format-coverage && npm run validate:web", + "validate:web": "cd clients/web && npm run validate", + }; + assert.ok(rootReachesScript(scripts, "verify:format-coverage")); + assert.ok(rootReachesScript(scripts, "validate:web")); + assert.ok(!rootReachesScript(scripts, "verify:typecheck-coverage")); +}); diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 272d226f3..858912ce2 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -40,7 +40,7 @@ import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { reachableScripts, rootReachesScript, @@ -53,10 +53,6 @@ const repoRoot = path.resolve( "..", ); -const rootPkg = JSON.parse( - readFileSync(path.join(repoRoot, "package.json"), "utf8"), -); - // Clients this guard deliberately does NOT gate, with the reason. The escape // hatch for a genuinely-ungateable client. Empty today: `clients/web` used to be // here (it has no `typecheck` script — its `build` runs `tsc -b`) but is now @@ -71,24 +67,24 @@ const EXEMPT = new Map(); // must be gated too). Ambient declaration files are excluded: an unreferenced // `*.d.{ts,mts,cts}` shim (e.g. web's `vitest.shims.d.ts`) is type-only and not // a real gap. There are no client `.mts`/`.cts` today; this pre-empts one. -const isRequiredSource = (rel) => +export const isRequiredSource = (rel) => /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); // Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ // tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. -const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); +export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); // A flag that makes a `tsc` pass list files without type-checking them (so it // gates nothing). Case-insensitive — tsc's own option parsing is. Shared by the // `typecheck`-script path and the reference (`tsc -b`) path. -const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); +export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); /** * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` * (a `tsc -b` solution config), or `[]` if it has none / isn't readable. Paths * are as written (relative to that tsconfig's own directory). */ -function tsconfigReferences(tsconfigRel) { +export function tsconfigReferences(tsconfigRel) { try { const raw = readFileSync(path.join(repoRoot, tsconfigRel), "utf8"); // Tolerate JSONC — block AND line comments + trailing commas (tsconfig @@ -115,7 +111,7 @@ function tsconfigReferences(tsconfigRel) { * client (like `clients/web`, which has no `typecheck` script) — this guard * enrolls it through these instead of exempting the whole tree. */ -function clientTsconfigReferences(clientDir) { +export function clientTsconfigReferences(clientDir) { return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json")); } @@ -125,7 +121,7 @@ function clientTsconfigReferences(clientDir) { * `--noCheck`/`--listFilesOnly` — lists files but checks nothing, the same hole * the `typecheck`-script path rejects), or `"none"` (no `tsc -b` at all). */ -function tscBuildStatus(scripts) { +export function tscBuildStatus(scripts) { let status = "none"; for (const name of reachableScripts(scripts, "validate")) { const cmd = scripts?.[name]; @@ -206,8 +202,6 @@ function nodeClients() { return { clients, problems }; } -const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); - /** * The tsconfig projects a client's `typecheck` names, harvested from **every** * script reachable from `typecheck` (not just the one string) so a delegating @@ -238,7 +232,7 @@ const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); * runs before tokenizing, so a quoted operator inside an arg would split * mid-token (project paths carry none of those). */ -function typecheckProjects(scripts) { +export function typecheckProjects(scripts) { const projects = []; const neutered = []; const isFlag = (t) => t.startsWith("-"); @@ -348,7 +342,7 @@ function rawProjectFiles(clientDir, project) { * the same graph, so a `noCheck` in a *referenced* project is caught no matter * which enrollment path harvested the solution. */ -function resolveLeafProjects(clientDir, project, seen = new Set()) { +export function resolveLeafProjects(clientDir, project, seen = new Set()) { if (seen.has(project)) return []; seen.add(project); // Lists files → a real leaf. (An empty set is a solution config, or a config @@ -449,168 +443,193 @@ function trackedNonClientSource() { .filter((f) => !f.startsWith("clients/")); } -// --------------------------------------------------------------------------- -// Phase 1 — gate integrity: is each client's `typecheck` actually run, and does -// it actually type-check? Reported (and exited) before the file-coverage pass -// so a mis-wired / inert gate isn't buried under a flood of consequent -// "in no tsconfig project" lines (which would list every file that gate covered). -// Also records, per client, the projects that genuinely type-check, for phase 2. -// Boundary: this does NOT detect shell-level failure suppression on the pass -// (`… || true`, `; exit 0`) — a pass that runs and checks but can't fail CI. -// --------------------------------------------------------------------------- -// A client that declares no `typecheck` and isn't exempt is a gate-integrity -// failure (seeded here so a renamed/removed `typecheck` is loud, not a silent -// drop). If that leaves nothing enrolled AND surfaced no such problem, the -// enumeration itself is broken (a moved `clients/` dir) — fail rather than no-op. -const integrity = [...enrollmentProblems]; -const checkingProjects = new Map(); -if (CLIENTS.length === 0 && integrity.length === 0) { - console.error( - "verify:typecheck-coverage — found no `clients/*` dir to check. The guard would check nothing; fix the enumeration.", +/** + * Run the guard: enumerate clients, check gate integrity (phase 1), then file + * coverage (phase 2). Prints its verdict and `process.exit(1)`s on any failure. + * Called only when this file is executed directly — importing it (for tests) + * gives access to the pure helpers above without running any of this. + */ +export function main() { + const rootPkg = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), ); - process.exit(1); -} + const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients(); -// Vouch for the sibling guard — a guard can't detect being unrun itself, but the -// two can each assert the other is still wired into `validate`, so dropping -// either is caught here (only deleting both slips through). -if (!rootReachesScript(rootPkg.scripts, "verify:format-coverage")) { - integrity.push( - "the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.", - ); -} + // ------------------------------------------------------------------------- + // Phase 1 — gate integrity: is each client's `typecheck` actually run, and + // does it actually type-check? Reported (and exited) before the file-coverage + // pass so a mis-wired / inert gate isn't buried under a flood of consequent + // "in no tsconfig project" lines. Records, per client, the projects that + // genuinely type-check, for phase 2. Boundary: does NOT detect shell-level + // failure suppression (`… || true`, `; exit 0`). + // ------------------------------------------------------------------------- + // A client that declares no `typecheck` and isn't exempt is a gate-integrity + // failure (seeded so a renamed/removed `typecheck` is loud, not a silent + // drop). If that leaves nothing enrolled AND surfaced no such problem, the + // enumeration itself is broken (a moved `clients/` dir) — fail, don't no-op. + const integrity = [...enrollmentProblems]; + const checkingProjects = new Map(); + if (CLIENTS.length === 0 && integrity.length === 0) { + console.error( + "verify:typecheck-coverage — found no `clients/*` dir to check. The guard would check nothing; fix the enumeration.", + ); + process.exit(1); + } -for (const clientDir of CLIENTS) { - checkingProjects.set(clientDir, []); - if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { + // Vouch for the sibling guard — a guard can't detect being unrun itself, but the + // two can each assert the other is still wired into `validate`, so dropping + // either is caught here (only deleting both slips through). + if (!rootReachesScript(rootPkg.scripts, "verify:format-coverage")) { integrity.push( - `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` (or \`npm --prefix ${clientDir} run validate\`) — its typecheck isn't invoked by CI.`, + "the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.", ); - continue; } - const scripts = JSON.parse( - readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), - ).scripts; - // Reference (`tsc -b`) client — no `typecheck` script; measured through its - // `tsconfig.json` `references`, and wired iff its `validate` runs a real - // (checking) `tsc -b`. - if (typeof scripts?.typecheck !== "string") { - const status = tscBuildStatus(scripts); - if (status !== "ok") { + for (const clientDir of CLIENTS) { + checkingProjects.set(clientDir, []); + if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { + integrity.push( + `${clientDir}: the root \`validate\` chain no longer runs \`cd ${clientDir} && npm run validate\` (or \`npm --prefix ${clientDir} run validate\`) — its typecheck isn't invoked by CI.`, + ); + continue; + } + const scripts = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ).scripts; + + // Reference (`tsc -b`) client — no `typecheck` script; measured through its + // `tsconfig.json` `references`, and wired iff its `validate` runs a real + // (checking) `tsc -b`. + if (typeof scripts?.typecheck !== "string") { + const status = tscBuildStatus(scripts); + if (status !== "ok") { + integrity.push( + status === "neutered" + ? `${clientDir}: its \`validate\`'s \`tsc -b\` carries \`--noCheck\`/\`--listFilesOnly\` — it lists files without type-checking them, so its references are gated by nothing.` + : `${clientDir}: no \`typecheck\` script and its \`validate\` never runs \`tsc -b\` — its \`tsconfig.json\` references are typechecked by nothing.`, + ); + continue; + } + checkingProjects.set( + clientDir, + checkingLeaves( + clientDir, + clientTsconfigReferences(clientDir), + integrity, + ), + ); + continue; + } + + if (!reachableScripts(scripts, "validate").has("typecheck")) { integrity.push( - status === "neutered" - ? `${clientDir}: its \`validate\`'s \`tsc -b\` carries \`--noCheck\`/\`--listFilesOnly\` — it lists files without type-checking them, so its references are gated by nothing.` - : `${clientDir}: no \`typecheck\` script and its \`validate\` never runs \`tsc -b\` — its \`tsconfig.json\` references are typechecked by nothing.`, + `${clientDir}: \`typecheck\` is not reachable from its \`validate\` — the typecheck it measures gates nothing.`, ); continue; } + const { projects, neutered } = typecheckProjects(scripts); + for (const { project, flag } of neutered) + integrity.push( + `${clientDir}: its \`typecheck\` runs \`-p ${project}\` with \`${flag}\` — that pass lists files without type-checking them, so it gates nothing.`, + ); + // Fire only when nothing was harvested at all — not when projects WERE named + // but every one is neutered (command flag) or config-disabled (`projects` was + // non-empty in that case; those get their own lines above). + if (projects.length === 0 && neutered.length === 0) + integrity.push( + `${clientDir}: its \`typecheck\` names no \`-p \` — nothing is typechecked.`, + ); + // Resolve each harvested project to its leaves (a `tsc -b` solution expands), + // and disable-check each leaf — so a `noCheck` in a referenced project counts. checkingProjects.set( clientDir, - checkingLeaves(clientDir, clientTsconfigReferences(clientDir), integrity), + checkingLeaves(clientDir, projects, integrity), ); - continue; } - if (!reachableScripts(scripts, "validate").has("typecheck")) { - integrity.push( - `${clientDir}: \`typecheck\` is not reachable from its \`validate\` — the typecheck it measures gates nothing.`, - ); - continue; - } - const { projects, neutered } = typecheckProjects(scripts); - for (const { project, flag } of neutered) - integrity.push( - `${clientDir}: its \`typecheck\` runs \`-p ${project}\` with \`${flag}\` — that pass lists files without type-checking them, so it gates nothing.`, + if (integrity.length > 0) { + console.error( + `verify:typecheck-coverage — ${integrity.length} gate-integrity issue(s): a typecheck gate is not run, or runs but checks nothing:\n`, ); - // Fire only when nothing was harvested at all — not when projects WERE named - // but every one is neutered (command flag) or config-disabled (`projects` was - // non-empty in that case; those get their own lines above). - if (projects.length === 0 && neutered.length === 0) - integrity.push( - `${clientDir}: its \`typecheck\` names no \`-p \` — nothing is typechecked.`, + for (const f of integrity) console.error(" " + f); + console.error( + "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass.", ); - // Resolve each harvested project to its leaves (a `tsc -b` solution expands), - // and disable-check each leaf — so a `noCheck` in a referenced project counts. - checkingProjects.set( - clientDir, - checkingLeaves(clientDir, projects, integrity), - ); -} - -if (integrity.length > 0) { - console.error( - `verify:typecheck-coverage — ${integrity.length} gate-integrity issue(s): a typecheck gate is not run, or runs but checks nothing:\n`, - ); - for (const f of integrity) console.error(" " + f); - console.error( - "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass.", - ); - process.exit(1); -} - -// --------------------------------------------------------------------------- -// Phase 2 — file coverage: every tracked source file lands in a checking project. -// --------------------------------------------------------------------------- -let totalChecked = 0; -const failures = []; -const globalCovered = new Set(); -for (const clientDir of CLIENTS) { - const covered = new Set(); - for (const project of checkingProjects.get(clientDir)) - for (const f of projectFiles(clientDir, project)) { - covered.add(f); - globalCovered.add(f); - } + process.exit(1); + } - const tracked = trackedSourceFiles(clientDir); - totalChecked += tracked.length; - for (const f of tracked) - if (!covered.has(f)) failures.push(`${f} — in no tsconfig project`); -} + // --------------------------------------------------------------------------- + // Phase 2 — file coverage: every tracked source file lands in a checking project. + // --------------------------------------------------------------------------- + let totalChecked = 0; + const failures = []; + const globalCovered = new Set(); + for (const clientDir of CLIENTS) { + const covered = new Set(); + for (const project of checkingProjects.get(clientDir)) + for (const f of projectFiles(clientDir, project)) { + covered.add(f); + globalCovered.add(f); + } -// Non-client first-party TS (shared source, plus anything at a new top-level -// location) gets no client of its own; require each to land in the GLOBAL union -// of client projects, so it's deny-by-default rather than an allowlist that a -// new location could fall outside. -const otherTracked = trackedNonClientSource(); -totalChecked += otherTracked.length; -const nonClientMisses = []; -for (const f of otherTracked) - if (!globalCovered.has(f)) { - nonClientMisses.push(f); - failures.push(`${f} — in no client's tsconfig project`); + const tracked = trackedSourceFiles(clientDir); + totalChecked += tracked.length; + for (const f of tracked) + if (!covered.has(f)) failures.push(`${f} — in no tsconfig project`); } -if (failures.length > 0) { - console.error( - `verify:typecheck-coverage — ${failures.length} tracked source file(s) get no \`tsc\` pass:\n`, - ); - for (const f of failures) console.error(" " + f); - console.error( - "\nAdd the file to a client's `tsconfig.json` / `tsconfig.test.json` `include` (a top-level config the build config's `rootDir` rejects goes in the test project).", - ); - if (failures.some((f) => /\.test\./.test(f))) - console.error( - "For a co-located test, instead move it to `__tests__/` — adding it to the src `include` would make the build emit it.", - ); - if (nonClientMisses.some((f) => f.startsWith("test-servers/"))) + // Non-client first-party TS (shared source, plus anything at a new top-level + // location) gets no client of its own; require each to land in the GLOBAL union + // of client projects, so it's deny-by-default rather than an allowlist that a + // new location could fall outside. + const otherTracked = trackedNonClientSource(); + totalChecked += otherTracked.length; + const nonClientMisses = []; + for (const f of otherTracked) + if (!globalCovered.has(f)) { + nonClientMisses.push(f); + failures.push(`${f} — in no client's tsconfig project`); + } + + if (failures.length > 0) { console.error( - "For a shared-source file no client imports (e.g. a `test-servers/src` bin entry), name it in a client `tsconfig.test.json`'s `include`, as `server-composable.ts` is.", + `verify:typecheck-coverage — ${failures.length} tracked source file(s) get no \`tsc\` pass:\n`, ); - if (nonClientMisses.some((f) => f.startsWith("core/"))) + for (const f of failures) console.error(" " + f); console.error( - "For a `core/` file web's `tsc -b` doesn't reach, widen the web project that owns it — `tsconfig.test.json` for `core/**/__tests__/**`, else `tsconfig.app.json` — not a cli/tui project, which shouldn't own `core`.", + "\nAdd the file to a client's `tsconfig.json` / `tsconfig.test.json` `include` (a top-level config the build config's `rootDir` rejects goes in the test project).", ); - console.error("See AGENTS.md."); - process.exit(1); + if (failures.some((f) => /\.test\./.test(f))) + console.error( + "For a co-located test, instead move it to `__tests__/` — adding it to the src `include` would make the build emit it.", + ); + if (nonClientMisses.some((f) => f.startsWith("test-servers/"))) + console.error( + "For a shared-source file no client imports (e.g. a `test-servers/src` bin entry), name it in a client `tsconfig.test.json`'s `include`, as `server-composable.ts` is.", + ); + if (nonClientMisses.some((f) => f.startsWith("core/"))) + console.error( + "For a `core/` file web's `tsc -b` doesn't reach, widen the web project that owns it — `tsconfig.test.json` for `core/**/__tests__/**`, else `tsconfig.app.json` — not a cli/tui project, which shouldn't own `core`.", + ); + if (nonClientMisses.some((f) => f.startsWith("scripts/"))) + console.error( + "For root tooling under `scripts/`, keep it `.mjs` (the convention there — prettier-gated via `format:check:scripts`, and `.mjs` isn't in this guard's required set), or give `scripts/` its own tsconfig project and enroll it here.", + ); + console.error("See AGENTS.md."); + process.exit(1); + } + + const exemptNote = [...EXEMPT.entries()] + .map(([dir, reason]) => `${dir} exempt: ${reason}`) + .join("; "); + console.log( + `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files ` + + `(${CLIENTS.length} clients + non-client) get a tsc pass` + + (exemptNote ? ` (${exemptNote}).` : "."), + ); } -const exemptNote = [...EXEMPT.entries()] - .map(([dir, reason]) => `${dir} exempt: ${reason}`) - .join("; "); -console.log( - `verify:typecheck-coverage — OK: all ${totalChecked} tracked source files ` + - `(${CLIENTS.length} clients + non-client) get a tsc pass` + - (exemptNote ? ` (${exemptNote}).` : "."), -); +// Run only when executed directly (`node scripts/verify-typecheck-coverage.mjs`); +// importing this file (tests) exposes the pure helpers without running the guard. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs new file mode 100644 index 000000000..4283d8f29 --- /dev/null +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -0,0 +1,99 @@ +// Table-driven tests for verify-typecheck-coverage's pure parsers. Importing the +// module exposes these without running the guard (its execution is behind +// `main()`, called only when the file is run directly). Each case pins a rule a +// #1799 review round found. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isDisablingFlag, + isRequiredSource, + isTsc, + tscBuildStatus, + typecheckProjects, +} from "./verify-typecheck-coverage.mjs"; + +test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => { + for (const f of ["a.ts", "a.tsx", "a.mts", "a.cts"]) + assert.ok(isRequiredSource(f), f); + for (const f of ["a.js", "a.d.ts", "a.d.mts", "a.d.cts", "a.json"]) + assert.ok(!isRequiredSource(f), f); +}); + +test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => { + for (const t of [ + "tsc", + "node_modules/.bin/tsc", + "./node_modules/.bin/tsc.cmd", + ]) + assert.ok(isTsc(t), t); + for (const t of ["vitest", "prettier", "tscx", "atsc"]) + assert.ok(!isTsc(t), t); +}); + +test("isDisablingFlag: case-insensitive (r18)", () => { + for (const t of [ + "--noCheck", + "--nocheck", + "--listFilesOnly", + "--LISTFILESONLY", + ]) + assert.ok(isDisablingFlag(t), t); + for (const t of ["--noEmit", "-p", "--project", "noCheck"]) + assert.ok(!isDisablingFlag(t), t); +}); + +test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => { + const { projects, neutered } = typecheckProjects({ + typecheck: + "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + }); + assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]); + assert.equal(neutered.length, 0); + + // A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json. + assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [ + "tsconfig.json", + ]); + + // Path-invoked binary still counts (r18). + assert.deepEqual( + typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" }) + .projects, + ["tsconfig.json"], + ); + + // A quoted project path (r17). + assert.deepEqual( + typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects, + ["tsconfig.test.json"], + ); +}); + +test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => { + const { projects, neutered } = typecheckProjects({ + typecheck: + "tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json", + }); + assert.deepEqual(projects, ["tsconfig.test.json"]); + assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]); +}); + +test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => { + const { projects } = typecheckProjects({ + typecheck: "npm run typecheck:src && npm run typecheck:test", + "typecheck:src": "tsc --noEmit -p tsconfig.json", + "typecheck:test": "tsc --noEmit --project tsconfig.test.json", + }); + assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]); +}); + +test("tscBuildStatus: ok / neutered / none (r25)", () => { + const status = (build) => + tscBuildStatus({ validate: "npm run build", build }); + assert.equal(status("tsc -b && vite build"), "ok"); + assert.equal(status("tsc --build"), "ok"); + assert.equal(status("tsc -b --noCheck && vite build"), "neutered"); + assert.equal(status("vite build"), "none"); + assert.equal(status("tsc --noEmit -p tsconfig.json"), "none"); // -b required +}); From 1bfa9b92d115dd051edd309f14aa85aaa4e697ed Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 08:49:38 -0400 Subject: [PATCH 31/39] test(scripts): make test:scripts self-guarding; fill parser test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the round-30 @claude review of #1799 (mutation testing killed 5/5; findings are about the new test gate + suite completeness): - Finding 1: `test:scripts` lacked the two properties the guard enforces for every tsc pass. verify-typecheck-coverage now asserts, in phase 1, that `test:scripts` is reachable from the root `validate` (via the already-imported rootReachesScript) AND that its tracked `scripts/**/*.{test,spec}.*` file set is non-empty and every file is matched by the command's glob (a new exported `globToRegExp`). So dropping it from validate, or renaming a test to `*.spec.mjs`/`*.test.mts` (which node --test silently skips), now hard-fails. Verified both. - Nit 2: extracted a pure `parseTsconfigReferences(raw)` from tsconfigReferences and table-tested the JSONC tolerance (block/line comments, trailing comma, no references, malformed, no path) — the r17/r26 rules had no cover. - Nit 3: added `-b`/`--build`/bare-`tsc -b` and explicit `--project` cases to typecheckProjects's test so its title matches its table. - Nit 4: rewrote the rootRunsClientValidate test to put `cmd` in a script reached from validate, and added the reachability cases (an orphan cd-validate must NOT count; validate:tui indirection must) — the r5 property. - Nit 5: added test:scripts to AGENTS's validate ordering + the README table. 15 guard tests now. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- AGENTS.md | 2 +- README.md | 1 + scripts/lib/npm-scripts.test.mjs | 29 ++++++++++- scripts/verify-typecheck-coverage.mjs | 60 +++++++++++++++++++++- scripts/verify-typecheck-coverage.test.mjs | 35 +++++++++++++ 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c082bcbc2..ca677bb0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,7 +229,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode. diff --git a/README.md b/README.md index bf9e97d7b..84050562c 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate`. | | `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 ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/lib/npm-scripts.test.mjs b/scripts/lib/npm-scripts.test.mjs index 07e49d3a6..a9408a6e1 100644 --- a/scripts/lib/npm-scripts.test.mjs +++ b/scripts/lib/npm-scripts.test.mjs @@ -60,8 +60,9 @@ test("reachableScripts: a `prevalidate`-hosted typecheck is reachable (r10)", () }); test("rootRunsClientValidate: forms that count vs. don't", () => { + // `cmd` lives in `vt`, reached from `validate` via `npm run vt`. const runs = (cmd) => - rootRunsClientValidate({ validate: cmd, x: cmd }, "clients/tui"); + rootRunsClientValidate({ validate: "npm run vt", vt: cmd }, "clients/tui"); // Counts: assert.ok(runs("cd clients/tui && npm run validate"), "plain cd"); assert.ok(runs("cd ./clients/tui && npm run validate"), "leading ./ (r15)"); @@ -87,6 +88,32 @@ test("rootRunsClientValidate: forms that count vs. don't", () => { assert.ok(!runs("cd clients/tui && npm run build"), "no validate"); }); +test("rootRunsClientValidate: only counts a reachable script (r5)", () => { + // A cd-validate call in a script nothing reachable from `validate` runs must + // NOT count — that's the whole reason the reachability restriction exists. + assert.ok( + !rootRunsClientValidate( + { + validate: "npm run something-else", + orphan: "cd clients/tui && npm run validate", + }, + "clients/tui", + ), + "orphan script isn't reachable from validate", + ); + // Reached via one hop of indirection counts. + assert.ok( + rootRunsClientValidate( + { + validate: "npm run validate:tui", + "validate:tui": "cd clients/tui && npm run validate", + }, + "clients/tui", + ), + "reached via validate:tui", + ); +}); + test("rootReachesScript: sibling-guard vouching", () => { const scripts = { validate: "npm run verify:format-coverage && npm run validate:web", diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 858912ce2..745445d71 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -79,14 +79,30 @@ export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); // `typecheck`-script path and the reference (`tsc -b`) path. export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); +/** A glob (with `**`/`*`) anchored to a RegExp over POSIX paths. Used to check a + * tracked test file is matched by the `test:scripts` command's glob. */ +export function globToRegExp(glob) { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { + re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"; + i += glob[i + 2] === "/" ? 2 : 1; + } else re += "[^/]*"; + } else if (".+^${}()|[]\\".includes(c)) re += "\\" + c; + else re += c; + } + return new RegExp("^" + re + "$"); +} + /** * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` * (a `tsc -b` solution config), or `[]` if it has none / isn't readable. Paths * are as written (relative to that tsconfig's own directory). */ -export function tsconfigReferences(tsconfigRel) { +export function parseTsconfigReferences(raw) { try { - const raw = readFileSync(path.join(repoRoot, tsconfigRel), "utf8"); // Tolerate JSONC — block AND line comments + trailing commas (tsconfig // allows all; block comments are in fact the style of every other tsconfig // here). Block comments are stripped first so a `//` inside one doesn't @@ -106,6 +122,16 @@ export function tsconfigReferences(tsconfigRel) { } } +export function tsconfigReferences(tsconfigRel) { + try { + return parseTsconfigReferences( + readFileSync(path.join(repoRoot, tsconfigRel), "utf8"), + ); + } catch { + return []; // unreadable file (e.g. a directory / missing path) + } +} + /** * The `references` in a client's root `tsconfig.json`. Non-empty for a `tsc -b` * client (like `clients/web`, which has no `typecheck` script) — this guard @@ -485,6 +511,36 @@ export function main() { ); } + // Vouch for `test:scripts` (this guard's OWN parser tests) the same way the + // guard vouches for a `tsc` pass: it must be run from `validate`, and its file + // set must be non-empty and fully covered by the command's glob — so a rename + // to `*.spec.mjs` / `*.test.mts` (which `node --test` silently skips, still + // exiting 0) can't quietly shrink the suite to nothing. + if (!rootReachesScript(rootPkg.scripts, "test:scripts")) { + integrity.push( + "the root `validate` no longer runs `test:scripts` — the guard's own parser tests run nowhere; restore it.", + ); + } else { + const testGlobs = tokenize(rootPkg.scripts["test:scripts"] ?? "").filter( + (t) => !t.startsWith("-") && t !== "node", + ); + const testFiles = execFileSync("git", ["ls-files", "scripts"], { + cwd: repoRoot, + encoding: "utf8", + }) + .split("\n") + .filter((f) => /\.(test|spec)\.[^/]+$/.test(f)); + if (testFiles.length === 0) + integrity.push( + "no `scripts/**/*.test.*` files are tracked — the guard's parser tests are gone.", + ); + for (const f of testFiles) + if (!testGlobs.some((g) => globToRegExp(g).test(f))) + integrity.push( + `${f}: not matched by the \`test:scripts\` glob — \`node --test\` won't run it (a rename to a form it skips). Rename it back to \`*.test.mjs\`.`, + ); + } + for (const clientDir of CLIENTS) { checkingProjects.set(clientDir, []); if (!rootRunsClientValidate(rootPkg.scripts, clientDir)) { diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 4283d8f29..0b0e527b2 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -6,9 +6,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + globToRegExp, isDisablingFlag, isRequiredSource, isTsc, + parseTsconfigReferences, tscBuildStatus, typecheckProjects, } from "./verify-typecheck-coverage.mjs"; @@ -68,6 +70,15 @@ test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects, ["tsconfig.test.json"], ); + + // `--project` long form, and `-b`/`--build` project paths (r13). + const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects; + assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [ + "tsconfig.json", + ]); + assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]); + assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]); + assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback }); test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => { @@ -97,3 +108,27 @@ test("tscBuildStatus: ok / neutered / none (r25)", () => { assert.equal(status("vite build"), "none"); assert.equal(status("tsc --noEmit -p tsconfig.json"), "none"); // -b required }); + +test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => { + const refs = (raw) => parseTsconfigReferences(raw); + assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]); + assert.deepEqual( + refs('/* solution */\n{ "references": [{ "path": "./a" }] }'), + ["./a"], + ); + assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [ + "./a", + ]); + assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma + assert.deepEqual(refs('{ "files": [] }'), []); // no references + assert.deepEqual(refs("{ not json"), []); // malformed + assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path +}); + +test("globToRegExp: matches the test:scripts glob shape", () => { + const g = globToRegExp("scripts/**/*.test.mjs"); + assert.ok(g.test("scripts/lib/npm-scripts.test.mjs")); + assert.ok(g.test("scripts/verify-typecheck-coverage.test.mjs")); // zero-depth ** + assert.ok(!g.test("scripts/lib/npm-scripts.spec.mjs")); // wrong suffix + assert.ok(!g.test("scripts/lib/npm-scripts.test.mts")); // wrong ext +}); From 901e4f85745aeb808d56b16d890c1738a7a4b870 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 09:08:11 -0400 Subject: [PATCH 32/39] =?UTF-8?q?chore(scripts):=20round-31=20=E2=80=94=20?= =?UTF-8?q?glob=20harvest=20follows=20delegation;=20brace/=3F=20globs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-31 review findings, both false failures in the new `test:scripts` self-guard: 1. The glob half read `rootPkg.scripts["test:scripts"]` literally while the wiring half two lines above used `reachableScripts`, so a delegating `test:scripts` (`npm run test:scripts:lib && npm run test:scripts:guard`) — which genuinely runs all 15 tests — reported every tracked test file as "not matched by the glob", advising a rename the files already satisfy. Now harvested across every script reachable FROM `test:scripts`, the same fix round 6 applied to the `typecheck` harvest; `pretest:scripts` counts for free. 2. `globToRegExp` escaped `{`/`}` as literals and left `?` as a regex quantifier, both divergent from `node --test`'s glob. `*.{test,spec}.mjs` is the natural widening here (the guard's own file scan already treats a `.spec.` file as a test) and false-failed; `?` silently meant "optional previous char" and threw outright when segment-leading. Adds `?` → `[^/]` and a brace-alternation pass, with an unbalanced brace falling back to a literal. Nit 3: `resolveLeafProjects`'s two path-resolution rules (the r26 directory-form reference, and refs resolving against the referring config's dir) extracted as `projectConfigFile`/`refToProject` so they are one-row tables like the other parsers, rather than untestable behind a `tsc` spawn. Nit 4: AGENTS.md + README document that the guard now enforces `test:scripts` on three axes (wired, non-empty, glob-covered). Verified: probes C and D exit 0; probe B (rename to `*.spec.mjs`) still caught; `test:scripts` 18/18; `verify:typecheck-coverage` OK 894 files; `verify:format-coverage` OK 919; `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- AGENTS.md | 2 +- README.md | 2 +- scripts/verify-typecheck-coverage.mjs | 78 ++++++++++++++++++---- scripts/verify-typecheck-coverage.test.mjs | 58 ++++++++++++++++ 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca677bb0b..08944062d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). diff --git a/README.md b/README.md index 84050562c..058fb77ea 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium 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). | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate`. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run 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 ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 745445d71..5dc2f3b3d 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -79,10 +79,26 @@ export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); // `typecheck`-script path and the reference (`tsc -b`) path. export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); -/** A glob (with `**`/`*`) anchored to a RegExp over POSIX paths. Used to check a - * tracked test file is matched by the `test:scripts` command's glob. */ +/** A glob anchored to a RegExp over POSIX paths. Used to check a tracked test + * file is matched by the `test:scripts` command's glob. Supports the forms + * `node --test`'s own glob does: `**` (any depth), `*` and `?` (within one + * segment), and brace alternation (`*.{test,spec}.mjs`) — the last two because + * `*.{test,spec}.mjs` is the natural widening here (the tracked-file scan + * already treats a `.spec.` file as a test), and a `?` left as a regex + * quantifier silently means "optional previous char" (or throws outright). */ export function globToRegExp(glob) { + // Only treat braces as alternation when they're balanced; an unbalanced one + // is a literal brace (and would otherwise emit an unclosed group). + let depth = 0; + let balanced = true; + for (const c of glob) { + if (c === "{") depth++; + else if (c === "}" && depth-- === 0) balanced = false; + } + if (depth !== 0) balanced = false; + let re = ""; + let open = 0; for (let i = 0; i < glob.length; i++) { const c = glob[i]; if (c === "*") { @@ -90,7 +106,15 @@ export function globToRegExp(glob) { re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"; i += glob[i + 2] === "/" ? 2 : 1; } else re += "[^/]*"; - } else if (".+^${}()|[]\\".includes(c)) re += "\\" + c; + } else if (c === "?") re += "[^/]"; + else if (balanced && c === "{") { + open++; + re += "(?:"; + } else if (balanced && c === "}" && open > 0) { + open--; + re += ")"; + } else if (balanced && c === "," && open > 0) re += "|"; + else if (".+^${}()|[]\\".includes(c)) re += "\\" + c; else re += c; } return new RegExp("^" + re + "$"); @@ -368,6 +392,30 @@ function rawProjectFiles(clientDir, project) { * the same graph, so a `noCheck` in a *referenced* project is caught no matter * which enrollment path harvested the solution. */ +/** + * The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names. + * A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means + * `/tsconfig.json` — tsc's own rule. + */ +export function projectConfigFile(clientDir, project) { + const projectRel = path.posix.join(clientDir, project); + return projectRel.endsWith(".json") + ? projectRel + : path.posix.join(projectRel, "tsconfig.json"); +} + +/** + * A `references` entry `ref` (written relative to `fromConfigFile`'s own + * directory) as a `clientDir`-relative project path, the form the rest of the + * graph walk uses. + */ +export function refToProject(clientDir, fromConfigFile, ref) { + return path.posix.relative( + clientDir, + path.posix.join(path.posix.dirname(fromConfigFile), ref), + ); +} + export function resolveLeafProjects(clientDir, project, seen = new Set()) { if (seen.has(project)) return []; seen.add(project); @@ -375,19 +423,13 @@ export function resolveLeafProjects(clientDir, project, seen = new Set()) { // that errored — either way the reference expansion below is the right next // step: a broken config yields no references too.) if (rawProjectFiles(clientDir, project).size > 0) return [project]; - // The config file `project` resolves to — a directory-form reference - // (`{ "path": "./packages/a" }`) means `/tsconfig.json`. - const projectRel = path.posix.join(clientDir, project); - const configFile = projectRel.endsWith(".json") - ? projectRel - : path.posix.join(projectRel, "tsconfig.json"); + const configFile = projectConfigFile(clientDir, project); const refs = tsconfigReferences(configFile); if (refs.length === 0) return [project]; // no files, no refs — itself - const configDir = path.posix.dirname(configFile); return refs.flatMap((ref) => resolveLeafProjects( clientDir, - path.posix.relative(clientDir, path.posix.join(configDir, ref)), + refToProject(clientDir, configFile, ref), seen, ), ); @@ -521,9 +563,17 @@ export function main() { "the root `validate` no longer runs `test:scripts` — the guard's own parser tests run nowhere; restore it.", ); } else { - const testGlobs = tokenize(rootPkg.scripts["test:scripts"] ?? "").filter( - (t) => !t.startsWith("-") && t !== "node", - ); + // Harvest across every script reachable FROM `test:scripts`, not just its + // own string — a delegating `test:scripts` ("npm run test:scripts:lib && + // npm run test:scripts:guard", the way other scripts here split) genuinely + // runs every test, and reading the one literal string would report each + // file as unmatched with unfollowable advice. Same fix as the `typecheck` + // harvest, and it picks up a `pretest:scripts` hook for free. + const testGlobs = [...reachableScripts(rootPkg.scripts, "test:scripts")] + .map((n) => rootPkg.scripts?.[n]) + .filter((c) => typeof c === "string") + .flatMap((c) => tokenize(c)) + .filter((t) => !t.startsWith("-") && t !== "node"); const testFiles = execFileSync("git", ["ls-files", "scripts"], { cwd: repoRoot, encoding: "utf8", diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 0b0e527b2..c796c9b74 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -11,6 +11,8 @@ import { isRequiredSource, isTsc, parseTsconfigReferences, + projectConfigFile, + refToProject, tscBuildStatus, typecheckProjects, } from "./verify-typecheck-coverage.mjs"; @@ -132,3 +134,59 @@ test("globToRegExp: matches the test:scripts glob shape", () => { assert.ok(!g.test("scripts/lib/npm-scripts.spec.mjs")); // wrong suffix assert.ok(!g.test("scripts/lib/npm-scripts.test.mts")); // wrong ext }); + +test("globToRegExp: brace alternation and ? (r31 finding 2)", () => { + // `node --test` expands braces, so the natural widening must match too. + const b = globToRegExp("scripts/**/*.{test,spec}.mjs"); + assert.ok(b.test("scripts/lib/npm-scripts.test.mjs")); + assert.ok(b.test("scripts/verify-typecheck-coverage.spec.mjs")); + assert.ok(!b.test("scripts/a.other.mjs")); + // `?` is exactly one non-separator char, not a regex quantifier. + const q = globToRegExp("scripts/a?.test.mjs"); + assert.ok(q.test("scripts/ab.test.mjs")); + assert.ok(!q.test("scripts/a.test.mjs")); // would pass if `?` stayed a quantifier + assert.ok(!q.test("scripts/a/.test.mjs")); // never crosses a separator + assert.doesNotThrow(() => globToRegExp("?.mjs")); // segment-leading `?` isn't `Nothing to repeat` + // An unbalanced brace is a literal brace, not an unclosed group. + assert.doesNotThrow(() => globToRegExp("scripts/{a.mjs")); + assert.ok(globToRegExp("scripts/{a.mjs").test("scripts/{a.mjs")); +}); + +test("projectConfigFile: directory-form entry means /tsconfig.json (r26)", () => { + assert.equal( + projectConfigFile("clients/cli", "tsconfig.test.json"), + "clients/cli/tsconfig.test.json", + ); + assert.equal( + projectConfigFile("clients/cli", "packages/a"), + "clients/cli/packages/a/tsconfig.json", + ); + assert.equal( + projectConfigFile("clients/cli", "."), + "clients/cli/tsconfig.json", + ); +}); + +test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => { + // A ref is relative to the tsconfig that declares it, not to clientDir. + assert.equal( + refToProject( + "clients/web", + "clients/web/tsconfig.json", + "./tsconfig.app.json", + ), + "tsconfig.app.json", + ); + assert.equal( + refToProject( + "clients/web", + "clients/web/sub/tsconfig.json", + "../other.json", + ), + "other.json", + ); + assert.equal( + refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"), + "sub/deep", + ); +}); From b05c56df4f5ad7e102efe095cb4fe8d8908b6452 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 09:26:41 -0400 Subject: [PATCH 33/39] =?UTF-8?q?chore(scripts):=20round-32=20=E2=80=94=20?= =?UTF-8?q?measure=20globs=20with=20node's=20own=20matcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-32 review; both findings point at deleting code. 1. `globToRegExp` hand-modeled `node --test`'s glob and was still one form short — character classes (`*[.]test.mjs`, `*.test.[mc]js`) hit the escape set and became literals, so the guard false-failed on files node genuinely runs. Replaced the whole translator (and its brace balance pre-scan) with node's `path.matchesGlob` — 22.5.0+, and the repo floor is >=22.19.0. Same *measure, don't model* move round 25 made when it deleted `isExemptCoreFile` in favor of measuring web's projects; being exact for every form node accepts is the point, since node's matcher is the thing being modeled. 2. The round-31 glob harvest was inline in `main()`, so mutation-reverting it to the literal single-script read left the suite green — the commit's headline fix was its one untested change. Extracted as `testScriptGlobs`, with rows for the direct form, the delegating form (both child globs, not `npm`/`run`/``), a `pretest:scripts` hook, and an unreachable script contributing nothing. Nit 3: `resolveLeafProjects`' JSDoc moved back onto it (the extraction left it stranded above `projectConfigFile`, which had two stacked doc blocks). Nit 4a: a `test:scripts` naming no path/glob harvested nothing, and `.some()` over an empty list blamed every test file with unfollowable advice. Now one explicit message naming the real problem. Verified: probes for the direct, delegating, brace, `[.]` and `[mc]` forms all exit 0 (the last two were false failures before); probe B (rename to `*.spec.mjs`) still caught; bare `node --test` reports the new single message; `test:scripts` 18/18; `verify:typecheck-coverage` OK 894; `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 121 ++++++++++----------- scripts/verify-typecheck-coverage.test.mjs | 60 ++++++---- 2 files changed, 93 insertions(+), 88 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 5dc2f3b3d..5add7ac1e 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -79,46 +79,36 @@ export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); // `typecheck`-script path and the reference (`tsc -b`) path. export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); -/** A glob anchored to a RegExp over POSIX paths. Used to check a tracked test - * file is matched by the `test:scripts` command's glob. Supports the forms - * `node --test`'s own glob does: `**` (any depth), `*` and `?` (within one - * segment), and brace alternation (`*.{test,spec}.mjs`) — the last two because - * `*.{test,spec}.mjs` is the natural widening here (the tracked-file scan - * already treats a `.spec.` file as a test), and a `?` left as a regex - * quantifier silently means "optional previous char" (or throws outright). */ -export function globToRegExp(glob) { - // Only treat braces as alternation when they're balanced; an unbalanced one - // is a literal brace (and would otherwise emit an unclosed group). - let depth = 0; - let balanced = true; - for (const c of glob) { - if (c === "{") depth++; - else if (c === "}" && depth-- === 0) balanced = false; - } - if (depth !== 0) balanced = false; - - let re = ""; - let open = 0; - for (let i = 0; i < glob.length; i++) { - const c = glob[i]; - if (c === "*") { - if (glob[i + 1] === "*") { - re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"; - i += glob[i + 2] === "/" ? 2 : 1; - } else re += "[^/]*"; - } else if (c === "?") re += "[^/]"; - else if (balanced && c === "{") { - open++; - re += "(?:"; - } else if (balanced && c === "}" && open > 0) { - open--; - re += ")"; - } else if (balanced && c === "," && open > 0) re += "|"; - else if (".+^${}()|[]\\".includes(c)) re += "\\" + c; - else re += c; - } - return new RegExp("^" + re + "$"); -} +/** + * Whether a tracked test file is matched by one of the `test:scripts` command's + * globs. Delegates to node's own `path.matchesGlob` (22.5.0+; the repo floor is + * `>=22.19.0`) rather than hand-translating the glob to a RegExp — `node --test` + * is the thing whose matching we're modeling, so measuring with node's matcher + * is exact for every form it accepts (`**` incl. zero depth, `*`, `?` as exactly + * one non-separator char, brace alternation, character classes) instead of + * false-failing on whichever form the translator hadn't covered yet. + */ +export const matchesTestGlob = (file, glob) => path.matchesGlob(file, glob); + +/** + * The path/glob arguments `test:scripts` hands `node --test`, harvested across + * every script reachable FROM it — not just its own literal string. A delegating + * `test:scripts` ("npm run test:scripts:lib && npm run test:scripts:guard", the + * way other scripts here split) genuinely runs every test, and reading the one + * string would instead harvest `npm`/`run`/`test:scripts:lib` and report each + * test file as unmatched with unfollowable advice. Same fix as the `typecheck` + * harvest, and it picks up a `pretest:scripts` hook for free. + * + * The filter is deliberately loose (any non-flag token that isn't `node`): a + * surplus token can only ADD a pattern that matches nothing, never suppress a + * real miss, since the call site requires SOME glob to match each file. + */ +export const testScriptGlobs = (scripts) => + [...reachableScripts(scripts, "test:scripts")] + .map((n) => scripts?.[n]) + .filter((c) => typeof c === "string") + .flatMap((c) => tokenize(c)) + .filter((t) => !t.startsWith("-") && t !== "node"); /** * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` @@ -382,16 +372,6 @@ function rawProjectFiles(clientDir, project) { return covered; } -/** - * The leaf tsconfig projects `project` resolves to (paths relative to - * `clientDir`): itself if it lists files (or has no `references`), else its - * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": - * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how - * it's reduced to the real projects — and doing it here (not just inside - * `projectFiles`) is what lets BOTH coverage and the non-inertness check follow - * the same graph, so a `noCheck` in a *referenced* project is caught no matter - * which enrollment path harvested the solution. - */ /** * The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names. * A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means @@ -416,6 +396,16 @@ export function refToProject(clientDir, fromConfigFile, ref) { ); } +/** + * The leaf tsconfig projects `project` resolves to (paths relative to + * `clientDir`): itself if it lists files (or has no `references`), else its + * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": + * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how + * it's reduced to the real projects — and doing it here (not just inside + * `projectFiles`) is what lets BOTH coverage and the non-inertness check follow + * the same graph, so a `noCheck` in a *referenced* project is caught no matter + * which enrollment path harvested the solution. + */ export function resolveLeafProjects(clientDir, project, seen = new Set()) { if (seen.has(project)) return []; seen.add(project); @@ -563,17 +553,7 @@ export function main() { "the root `validate` no longer runs `test:scripts` — the guard's own parser tests run nowhere; restore it.", ); } else { - // Harvest across every script reachable FROM `test:scripts`, not just its - // own string — a delegating `test:scripts` ("npm run test:scripts:lib && - // npm run test:scripts:guard", the way other scripts here split) genuinely - // runs every test, and reading the one literal string would report each - // file as unmatched with unfollowable advice. Same fix as the `typecheck` - // harvest, and it picks up a `pretest:scripts` hook for free. - const testGlobs = [...reachableScripts(rootPkg.scripts, "test:scripts")] - .map((n) => rootPkg.scripts?.[n]) - .filter((c) => typeof c === "string") - .flatMap((c) => tokenize(c)) - .filter((t) => !t.startsWith("-") && t !== "node"); + const testGlobs = testScriptGlobs(rootPkg.scripts); const testFiles = execFileSync("git", ["ls-files", "scripts"], { cwd: repoRoot, encoding: "utf8", @@ -584,11 +564,20 @@ export function main() { integrity.push( "no `scripts/**/*.test.*` files are tracked — the guard's parser tests are gone.", ); - for (const f of testFiles) - if (!testGlobs.some((g) => globToRegExp(g).test(f))) - integrity.push( - `${f}: not matched by the \`test:scripts\` glob — \`node --test\` won't run it (a rename to a form it skips). Rename it back to \`*.test.mjs\`.`, - ); + if (testGlobs.length === 0) { + // A bare `node --test` (auto-discovery from the cwd) harvests nothing, and + // `.some()` over an empty list would blame every file individually with + // advice none of them can follow. Say the actual problem once instead. + integrity.push( + '`test:scripts` names no path/glob — this guard can\'t tell which files `node --test` runs. Give it an explicit glob (e.g. `node --test "scripts/**/*.test.mjs"`).', + ); + } else { + for (const f of testFiles) + if (!testGlobs.some((g) => matchesTestGlob(f, g))) + integrity.push( + `${f}: not matched by the \`test:scripts\` glob — \`node --test\` won't run it (a rename to a form it skips). Rename it back to \`*.test.mjs\`.`, + ); + } } for (const clientDir of CLIENTS) { diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index c796c9b74..9fecd3014 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -6,13 +6,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { - globToRegExp, + matchesTestGlob, isDisablingFlag, isRequiredSource, isTsc, parseTsconfigReferences, projectConfigFile, refToProject, + testScriptGlobs, tscBuildStatus, typecheckProjects, } from "./verify-typecheck-coverage.mjs"; @@ -127,29 +128,44 @@ test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path }); -test("globToRegExp: matches the test:scripts glob shape", () => { - const g = globToRegExp("scripts/**/*.test.mjs"); - assert.ok(g.test("scripts/lib/npm-scripts.test.mjs")); - assert.ok(g.test("scripts/verify-typecheck-coverage.test.mjs")); // zero-depth ** - assert.ok(!g.test("scripts/lib/npm-scripts.spec.mjs")); // wrong suffix - assert.ok(!g.test("scripts/lib/npm-scripts.test.mts")); // wrong ext +test("matchesTestGlob: the guard's contract, not node's glob engine", () => { + // Only the two properties the guard actually relies on — the rest of node's + // glob semantics are node's to test, which is the point of delegating to it. + const g = "scripts/**/*.test.mjs"; + assert.ok(matchesTestGlob("scripts/verify-typecheck-coverage.test.mjs", g)); // zero-depth ** + assert.ok(matchesTestGlob("scripts/lib/npm-scripts.test.mjs", g)); // nested + assert.ok(!matchesTestGlob("scripts/lib/npm-scripts.spec.mjs", g)); // the probe-B rename }); -test("globToRegExp: brace alternation and ? (r31 finding 2)", () => { - // `node --test` expands braces, so the natural widening must match too. - const b = globToRegExp("scripts/**/*.{test,spec}.mjs"); - assert.ok(b.test("scripts/lib/npm-scripts.test.mjs")); - assert.ok(b.test("scripts/verify-typecheck-coverage.spec.mjs")); - assert.ok(!b.test("scripts/a.other.mjs")); - // `?` is exactly one non-separator char, not a regex quantifier. - const q = globToRegExp("scripts/a?.test.mjs"); - assert.ok(q.test("scripts/ab.test.mjs")); - assert.ok(!q.test("scripts/a.test.mjs")); // would pass if `?` stayed a quantifier - assert.ok(!q.test("scripts/a/.test.mjs")); // never crosses a separator - assert.doesNotThrow(() => globToRegExp("?.mjs")); // segment-leading `?` isn't `Nothing to repeat` - // An unbalanced brace is a literal brace, not an unclosed group. - assert.doesNotThrow(() => globToRegExp("scripts/{a.mjs")); - assert.ok(globToRegExp("scripts/{a.mjs").test("scripts/{a.mjs")); +test("testScriptGlobs: harvests across delegation (r31 finding 1 / r32 finding 2)", () => { + // Direct form — the glob itself, with `node` and flags dropped. + assert.deepEqual( + testScriptGlobs({ "test:scripts": 'node --test "scripts/**/*.test.mjs"' }), + ["scripts/**/*.test.mjs"], + ); + // Delegating form — BOTH child globs, not the literal npm/run/ tokens. + const delegating = testScriptGlobs({ + "test:scripts": "npm run test:scripts:lib && npm run test:scripts:guard", + "test:scripts:lib": 'node --test "scripts/lib/**/*.test.mjs"', + "test:scripts:guard": 'node --test "scripts/*.test.mjs"', + }); + assert.ok(delegating.includes("scripts/lib/**/*.test.mjs")); + assert.ok(delegating.includes("scripts/*.test.mjs")); + // A pre hook is reached too (npm runs it implicitly). + assert.ok( + testScriptGlobs({ + "test:scripts": "node --test scripts/a.test.mjs", + "pretest:scripts": "node --test scripts/b.test.mjs", + }).includes("scripts/b.test.mjs"), + ); + // An unreachable script contributes nothing. + assert.deepEqual( + testScriptGlobs({ + "test:scripts": "node --test scripts/a.test.mjs", + other: "node --test scripts/z.test.mjs", + }), + ["scripts/a.test.mjs"], + ); }); test("projectConfigFile: directory-form entry means /tsconfig.json (r26)", () => { From d6964dce9a5b9b435a2de658c67644613d82dbfa Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:03:25 -0400 Subject: [PATCH 34/39] =?UTF-8?q?chore(scripts):=20round-33=20=E2=80=94=20?= =?UTF-8?q?scope=20the=20glob=20harvest=20to=20`node=20--test`=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. False pass. `testScriptGlobs` kept every non-flag token from every reachable script, so a glob belonging to a NEIGHBOURING command was attributed to the test runner — and since `.some()` needs only one glob to match, a broader one suppressed a real miss rather than adding an inert pattern. A `pretest:scripts` of `prettier --check "scripts/**/*.mjs"` (reachable via the pre-hook round 31 added) was enough: the probe-B rename to `*.spec.mjs` then passed while `node --test` silently ran 12 of 18 tests. Now scoped per command segment to the ones invoking `--test`, exactly as `typecheckProjects` gates on `tokens.some(isTsc)`. The JSDoc paragraph claiming a surplus token can't suppress a miss is corrected — it was false. 2. The gate-integrity block is extracted as `testScriptProblems(scripts, testFiles)`, pure and outside `main()`. The nit-4a branch added last round was the third consecutive commit whose newest branch sat outside the test seam and mutation-survived; extracting the block ends the pattern rather than pinning one more condition from the outside. Verified. The finding-1 probe (prettier pre-hook + `*.spec.mjs` rename) now exits 1 where it exited 0; the hook alone still exits 0. Mutation testing over the whole helper — empty-harvest branch to `if (false)`, segment gate removed, wiring axis removed, non-empty axis removed, segment split removed, `reachableScripts` to `Object.keys`, `matchesTestGlob` to equality — kills 7/7, including the branch that survived the last three rounds. `test:scripts` 21/21 · `verify:typecheck-coverage` OK 894 · `verify:format-coverage` OK 919 · `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 109 +++++++++++++-------- scripts/verify-typecheck-coverage.test.mjs | 76 ++++++++++++++ 2 files changed, 143 insertions(+), 42 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 5add7ac1e..2fc139a13 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -96,20 +96,70 @@ export const matchesTestGlob = (file, glob) => path.matchesGlob(file, glob); * `test:scripts` ("npm run test:scripts:lib && npm run test:scripts:guard", the * way other scripts here split) genuinely runs every test, and reading the one * string would instead harvest `npm`/`run`/`test:scripts:lib` and report each - * test file as unmatched with unfollowable advice. Same fix as the `typecheck` - * harvest, and it picks up a `pretest:scripts` hook for free. + * test file as unmatched with unfollowable advice. Reachability also picks up a + * `pretest:scripts` hook for free. * - * The filter is deliberately loose (any non-flag token that isn't `node`): a - * surplus token can only ADD a pattern that matches nothing, never suppress a - * real miss, since the call site requires SOME glob to match each file. + * Scoped per COMMAND SEGMENT to the ones that actually invoke `node --test`, + * exactly as `typecheckProjects` gates on `tokens.some(isTsc)` before harvesting + * `-p` args. Without that scoping a glob belonging to a *neighbouring* command + * is attributed to the test runner — and since `.some()` only needs ONE glob to + * match, a broader one silently suppresses a real miss rather than adding an + * inert pattern. A `pretest:scripts` of `prettier --check "scripts/**\/*.mjs"` + * is enough to do it: that glob matches a renamed `*.spec.mjs`, so the rename + * `node --test` skips would pass the check. Reachability widened what's read; + * this keeps what's *harvested* to the runner's own arguments. */ export const testScriptGlobs = (scripts) => [...reachableScripts(scripts, "test:scripts")] .map((n) => scripts?.[n]) .filter((c) => typeof c === "string") - .flatMap((c) => tokenize(c)) + .flatMap((c) => c.split(/&&|\|\||;/)) + .map((segment) => tokenize(segment)) + .filter((tokens) => tokens.includes("--test")) // only `node --test` names test files + .flat() .filter((t) => !t.startsWith("-") && t !== "node"); +/** + * Gate-integrity problems with `test:scripts` — this guard's OWN parser tests — + * given the root `scripts` and the tracked `scripts/**` test files. Vouched for + * the same way the guard vouches for a `tsc` pass, on three axes: it must be run + * from `validate`, its file set must be non-empty, and every one of those files + * must be matched by a glob the runner is actually given — because `node --test` + * SKIPS a file its glob misses and still exits 0, so a rename to `*.spec.mjs` + * would shrink the suite with a green run. + * + * Pure, and separate from `main()`, so each branch below is reachable from the + * test suite: every previous round put the newest branch inside `main()`, where + * mutation testing found it untested three rounds running. + */ +export function testScriptProblems(scripts, testFiles) { + if (!rootReachesScript(scripts, "test:scripts")) + return [ + "the root `validate` no longer runs `test:scripts` — the guard's own parser tests run nowhere; restore it.", + ]; + const problems = []; + if (testFiles.length === 0) + problems.push( + "no `scripts/**/*.test.*` files are tracked — the guard's parser tests are gone.", + ); + const globs = testScriptGlobs(scripts); + if (globs.length === 0) { + // A bare `node --test` (auto-discovery from the cwd) names no glob, and + // `.some()` over an empty list would blame every file individually with + // advice none of them can follow. Say the actual problem once instead. + problems.push( + '`test:scripts` names no path/glob — this guard can\'t tell which files `node --test` runs. Give it an explicit glob (e.g. `node --test "scripts/**/*.test.mjs"`).', + ); + return problems; + } + for (const f of testFiles) + if (!globs.some((g) => matchesTestGlob(f, g))) + problems.push( + `${f}: not matched by the \`test:scripts\` glob — \`node --test\` won't run it (a rename to a form it skips). Rename it back to \`*.test.mjs\`.`, + ); + return problems; +} + /** * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` * (a `tsc -b` solution config), or `[]` if it has none / isn't readable. Paths @@ -543,42 +593,17 @@ export function main() { ); } - // Vouch for `test:scripts` (this guard's OWN parser tests) the same way the - // guard vouches for a `tsc` pass: it must be run from `validate`, and its file - // set must be non-empty and fully covered by the command's glob — so a rename - // to `*.spec.mjs` / `*.test.mts` (which `node --test` silently skips, still - // exiting 0) can't quietly shrink the suite to nothing. - if (!rootReachesScript(rootPkg.scripts, "test:scripts")) { - integrity.push( - "the root `validate` no longer runs `test:scripts` — the guard's own parser tests run nowhere; restore it.", - ); - } else { - const testGlobs = testScriptGlobs(rootPkg.scripts); - const testFiles = execFileSync("git", ["ls-files", "scripts"], { - cwd: repoRoot, - encoding: "utf8", - }) - .split("\n") - .filter((f) => /\.(test|spec)\.[^/]+$/.test(f)); - if (testFiles.length === 0) - integrity.push( - "no `scripts/**/*.test.*` files are tracked — the guard's parser tests are gone.", - ); - if (testGlobs.length === 0) { - // A bare `node --test` (auto-discovery from the cwd) harvests nothing, and - // `.some()` over an empty list would blame every file individually with - // advice none of them can follow. Say the actual problem once instead. - integrity.push( - '`test:scripts` names no path/glob — this guard can\'t tell which files `node --test` runs. Give it an explicit glob (e.g. `node --test "scripts/**/*.test.mjs"`).', - ); - } else { - for (const f of testFiles) - if (!testGlobs.some((g) => matchesTestGlob(f, g))) - integrity.push( - `${f}: not matched by the \`test:scripts\` glob — \`node --test\` won't run it (a rename to a form it skips). Rename it back to \`*.test.mjs\`.`, - ); - } - } + integrity.push( + ...testScriptProblems( + rootPkg.scripts, + execFileSync("git", ["ls-files", "scripts"], { + cwd: repoRoot, + encoding: "utf8", + }) + .split("\n") + .filter((f) => /\.(test|spec)\.[^/]+$/.test(f)), + ), + ); for (const clientDir of CLIENTS) { checkingProjects.set(clientDir, []); diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 9fecd3014..29919b190 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -14,6 +14,7 @@ import { projectConfigFile, refToProject, testScriptGlobs, + testScriptProblems, tscBuildStatus, typecheckProjects, } from "./verify-typecheck-coverage.mjs"; @@ -168,6 +169,81 @@ test("testScriptGlobs: harvests across delegation (r31 finding 1 / r32 finding 2 ); }); +test("testScriptGlobs: only `node --test` segments contribute (r33 finding 1)", () => { + // A reachable NON-runner command's glob must not be attributed to the runner: + // `scripts/**/*.mjs` matches a renamed `*.spec.mjs`, so harvesting it would + // make the probe-B rename pass while `node --test` silently ran 6 fewer tests. + assert.deepEqual( + testScriptGlobs({ + "test:scripts": 'node --test "scripts/**/*.test.mjs"', + "pretest:scripts": 'prettier --check "scripts/**/*.mjs"', + }), + ["scripts/**/*.test.mjs"], + ); + // Same within one command: only the `--test` segment's args are harvested. + assert.deepEqual( + testScriptGlobs({ + "test:scripts": + 'prettier --check "scripts/**/*.mjs" && node --test "scripts/**/*.test.mjs"', + }), + ["scripts/**/*.test.mjs"], + ); +}); + +test("testScriptGlobs: empty when no glob is named (r33 finding 2)", () => { + // The condition the "`test:scripts` names no path/glob" branch keys off — a + // bare `node --test` auto-discovers, so the guard can't tell what it runs. + assert.deepEqual(testScriptGlobs({ "test:scripts": "node --test" }), []); + assert.deepEqual(testScriptGlobs({}), []); +}); + +test("testScriptProblems: all three axes (r33 finding 2)", () => { + const WIRED = { + validate: "npm run test:scripts", + "test:scripts": 'node --test "scripts/**/*.test.mjs"', + }; + const only = (p) => (assert.equal(p.length, 1, p.join("\n")), p[0]); + + // Green: wired, non-empty, every file glob-matched. + assert.deepEqual(testScriptProblems(WIRED, ["scripts/a.test.mjs"]), []); + + // Axis 1 — not reachable from `validate`. Reported alone: with the tests + // unrun, the other two axes are moot. + assert.match( + only( + testScriptProblems( + { validate: "echo hi", "test:scripts": WIRED["test:scripts"] }, + ["scripts/a.test.mjs"], + ), + ), + /no longer runs `test:scripts`/, + ); + + // Axis 2 — no tracked test files at all. + assert.match( + only(testScriptProblems(WIRED, [])), + /no `scripts\/\*\*\/\*\.test\.\*` files are tracked/, + ); + + // Axis 3 — a file `node --test` would skip (the probe-B rename). + assert.match( + only(testScriptProblems(WIRED, ["scripts/a.spec.mjs"])), + /^scripts\/a\.spec\.mjs: not matched by the `test:scripts` glob/, + ); + + // The empty-harvest branch: ONE message naming the real problem, not one + // unfollowable blame per file. (`if (false)`-mutating it yields 2 messages.) + assert.match( + only( + testScriptProblems( + { validate: "npm run test:scripts", "test:scripts": "node --test" }, + ["scripts/a.test.mjs", "scripts/b.test.mjs"], + ), + ), + /names no path\/glob/, + ); +}); + test("projectConfigFile: directory-form entry means /tsconfig.json (r26)", () => { assert.equal( projectConfigFile("clients/cli", "tsconfig.test.json"), From 41175adf80227973c7f9e02165587cb63b110c11 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:23:42 -0400 Subject: [PATCH 35/39] =?UTF-8?q?chore(scripts):=20round-34=20=E2=80=94=20?= =?UTF-8?q?harvest=20only=20positional=20args;=20normalize=20`./`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are the axis round 33 fixed, one level in. 1. False pass. The segment gate stopped a neighbouring COMMAND's glob, but within a kept segment the filter was still "any non-flag token", so a glob-valued flag's VALUE was harvested as a positional arg. `--test-coverage-include "scripts/**/*.mjs"` is broader than the test glob and matches a renamed `*.spec.mjs`, so it vouched for the very file the runner skips — and it isn't hypothetical: `scripts/` sits outside the ≥90 coverage gate, so that flag is the obvious next thing added here. Tokens preceded by a value-taking `--test-*` flag are now dropped. 2. False failure. `./scripts/**/*.test.mjs` — which `node --test` accepts, and the common npm-script idiom — matched nothing, because `git ls-files` emits no `./`. Both files were then blamed for a rename that never happened, the unfollowable-remediation shape fixed for the bare-runner case last round. A leading `./` is normalized off, as `rootRunsClientValidate` already does. Nit 4: the gate-integrity footer's typecheck-wiring advice is now conditioned on at least one issue being about the typecheck wiring — a `test:scripts` failure carries its own per-line remediation, and appending `--noCheck`/`tsc -b` advice under a renamed-test-file message sends the reader after the wrong thing. Verified. The coverage-include probe with the rename now exits 1 where it exited 0, and exits 0 without the rename; the `./` probe exits 0 where it exited 1; a `test:scripts`-only failure no longer prints the typecheck footer. Mutation sweep 9/9 killed, including both new rules. `test:scripts` 22/22 · `verify:typecheck-coverage` OK 894 · `verify:format-coverage` OK 919 · `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 61 ++++++++++++++++------ scripts/verify-typecheck-coverage.test.mjs | 32 ++++++++++++ 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 2fc139a13..f2d76042a 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -90,6 +90,17 @@ export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); */ export const matchesTestGlob = (file, glob) => path.matchesGlob(file, glob); +// `node --test` flags whose VALUE is a separate token — never a positional glob. +const VALUE_TAKING_TEST_FLAGS = new Set([ + "--test-coverage-include", + "--test-coverage-exclude", + "--test-reporter", + "--test-reporter-destination", + "--test-name-pattern", + "--test-skip-pattern", + "--test-shard", +]); + /** * The path/glob arguments `test:scripts` hands `node --test`, harvested across * every script reachable FROM it — not just its own literal string. A delegating @@ -108,6 +119,15 @@ export const matchesTestGlob = (file, glob) => path.matchesGlob(file, glob); * is enough to do it: that glob matches a renamed `*.spec.mjs`, so the rename * `node --test` skips would pass the check. Reachability widened what's read; * this keeps what's *harvested* to the runner's own arguments. + * + * Within a kept segment only the POSITIONAL args count. `--test-coverage-include + * "scripts/**\/*.mjs"` is the same suppression one level in — a glob-valued flag + * broader than the test glob, which would vouch for the very file the runner + * skips (and it's the obvious next addition here, since `scripts/` sits outside + * the ≥90 coverage gate). A leading `./` is normalized off because `git ls-files` + * emits none, so `./scripts/…` — the common npm-script idiom, which `node --test` + * accepts — would otherwise match nothing and blame every file for a rename that + * never happened (`rootRunsClientValidate` normalizes it for the same reason). */ export const testScriptGlobs = (scripts) => [...reachableScripts(scripts, "test:scripts")] @@ -116,8 +136,15 @@ export const testScriptGlobs = (scripts) => .flatMap((c) => c.split(/&&|\|\||;/)) .map((segment) => tokenize(segment)) .filter((tokens) => tokens.includes("--test")) // only `node --test` names test files - .flat() - .filter((t) => !t.startsWith("-") && t !== "node"); + .flatMap((tokens) => + tokens.filter( + (t, i) => + !t.startsWith("-") && + t !== "node" && + !VALUE_TAKING_TEST_FLAGS.has(tokens[i - 1]), + ), + ) + .map((g) => g.replace(/^\.\//, "")); /** * Gate-integrity problems with `test:scripts` — this guard's OWN parser tests — @@ -593,17 +620,16 @@ export function main() { ); } - integrity.push( - ...testScriptProblems( - rootPkg.scripts, - execFileSync("git", ["ls-files", "scripts"], { - cwd: repoRoot, - encoding: "utf8", - }) - .split("\n") - .filter((f) => /\.(test|spec)\.[^/]+$/.test(f)), - ), + const testScriptIssues = testScriptProblems( + rootPkg.scripts, + execFileSync("git", ["ls-files", "scripts"], { + cwd: repoRoot, + encoding: "utf8", + }) + .split("\n") + .filter((f) => /\.(test|spec)\.[^/]+$/.test(f)), ); + integrity.push(...testScriptIssues); for (const clientDir of CLIENTS) { checkingProjects.set(clientDir, []); @@ -672,9 +698,14 @@ export function main() { `verify:typecheck-coverage — ${integrity.length} gate-integrity issue(s): a typecheck gate is not run, or runs but checks nothing:\n`, ); for (const f of integrity) console.error(" " + f); - console.error( - "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass.", - ); + // Only advise on the typecheck wiring when something above is ABOUT it — a + // `test:scripts` issue carries its own remediation per line, and appending + // `--noCheck`/`tsc -b` advice under a renamed-test-file message sends the + // reader after the wrong thing. + if (integrity.some((f) => !testScriptIssues.includes(f))) + console.error( + "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass.", + ); process.exit(1); } diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 29919b190..4bae687d8 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -190,6 +190,38 @@ test("testScriptGlobs: only `node --test` segments contribute (r33 finding 1)", ); }); +test("testScriptGlobs: flag values and ./ prefixes (r34 findings 1 & 2)", () => { + // A glob-valued `--test-*` flag is NOT a positional arg. `scripts/**/*.mjs` + // matches a renamed `*.spec.mjs`, so harvesting it would vouch for the very + // file the runner skips — the r33 suppression one level in. + assert.deepEqual( + testScriptGlobs({ + "test:scripts": + 'node --test --experimental-test-coverage --test-coverage-include "scripts/**/*.mjs" "scripts/**/*.test.mjs"', + }), + ["scripts/**/*.test.mjs"], + ); + // A leading `./` is normalized off — `node --test` accepts it, `git ls-files` + // never emits it, so keeping it would blame every file for a rename that + // never happened. + assert.deepEqual( + testScriptGlobs({ + "test:scripts": 'node --test "./scripts/**/*.test.mjs"', + }), + ["scripts/**/*.test.mjs"], + ); + // …and the normalized glob really does match what `git ls-files` emits. + assert.ok( + testScriptProblems( + { + validate: "npm run test:scripts", + "test:scripts": 'node --test "./scripts/**/*.test.mjs"', + }, + ["scripts/lib/npm-scripts.test.mjs"], + ).length === 0, + ); +}); + test("testScriptGlobs: empty when no glob is named (r33 finding 2)", () => { // The condition the "`test:scripts` names no path/glob" branch keys off — a // bare `node --test` auto-discovers, so the guard can't tell what it runs. From 696bf4c4d5f1d7e6ddda7ea5336c3b8902687bfa Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 10:43:55 -0400 Subject: [PATCH 36/39] =?UTF-8?q?chore(scripts):=20round-35=20=E2=80=94=20?= =?UTF-8?q?fourth=20axis:=20flags=20that=20narrow=20the=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. False pass on a new axis. The three axes ask whether the tests are run, whether there are any, and whether the runner is told about each — none asks what it then DOES with them. `--test-shard 1/2` keeps every glob intact and executes 6 of 22 tests, exiting 0, with the guard green; `--test-only` runs 2, as does a non-matching `--test-name-pattern`. This is `--noCheck` for the test runner, one domain over. Adds `NARROWING_TEST_FLAGS` and a fourth axis in `testScriptProblems`. Three of those flags were already in `VALUE_TAKING_TEST_FLAGS` — the guard knew them well enough to drop their values but not that they shrink the run. Both `--flag value` and `--flag=value` report as the bare flag name. 2. The footer conditioning added last round was itself inside `main()` — the pattern the same commit's comment documented, recurring in the commit that documented it. Extracted as `integrityAdvice(problems, nonTypecheck)`. Doing that fixes nit 3 in the same move: the old condition keyed off `testScriptIssues` specifically, so it covered one of three non-typecheck categories. A sibling-vouch failure ("the root validate no longer runs verify:format-coverage") and client-enrollment problems still got `--noCheck`/`tsc -b` advice about a typecheck pass they have nothing to do with. All three sets are now excluded. Verified. `--test-shard 1/2`, `--test-only` and `--test-name-pattern zzz` each now exit 1 naming the flag, while node runs 6/2/2 of 22. A sibling-vouch failure no longer prints the typecheck footer. Mutation sweep 12/12 killed, including all three new rules and the branch this commit replaces. `test:scripts` 24/24 · `verify:typecheck-coverage` OK 894 · `verify:format-coverage` OK 919 · `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 74 ++++++++++++++++++---- scripts/verify-typecheck-coverage.test.mjs | 68 ++++++++++++++++++++ 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index f2d76042a..bd3b9ff78 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -90,6 +90,16 @@ export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); */ export const matchesTestGlob = (file, glob) => path.matchesGlob(file, glob); +// `node --test` flags that make it execute only PART of the suite its globs +// match, while still exiting 0. This is `--noCheck` for the test runner: the +// gate runs, but gates less than it appears to. +const NARROWING_TEST_FLAGS = new Set([ + "--test-shard", + "--test-only", + "--test-name-pattern", + "--test-skip-pattern", +]); + // `node --test` flags whose VALUE is a separate token — never a positional glob. const VALUE_TAKING_TEST_FLAGS = new Set([ "--test-coverage-include", @@ -129,13 +139,16 @@ const VALUE_TAKING_TEST_FLAGS = new Set([ * accepts — would otherwise match nothing and blame every file for a rename that * never happened (`rootRunsClientValidate` normalizes it for the same reason). */ -export const testScriptGlobs = (scripts) => +const testRunnerSegments = (scripts) => [...reachableScripts(scripts, "test:scripts")] .map((n) => scripts?.[n]) .filter((c) => typeof c === "string") .flatMap((c) => c.split(/&&|\|\||;/)) .map((segment) => tokenize(segment)) - .filter((tokens) => tokens.includes("--test")) // only `node --test` names test files + .filter((tokens) => tokens.includes("--test")); // only `node --test` names test files + +export const testScriptGlobs = (scripts) => + testRunnerSegments(scripts) .flatMap((tokens) => tokens.filter( (t, i) => @@ -146,6 +159,22 @@ export const testScriptGlobs = (scripts) => ) .map((g) => g.replace(/^\.\//, "")); +/** + * Suite-narrowing flags the `test:scripts` runner is given. The other three axes + * ask whether the tests are RUN, whether there are any, and whether the runner + * is told about each — none asks what it then DOES with them. `--test-shard 1/2` + * keeps every glob intact and executes half the suite, exiting 0; `--test-only` + * and the name/skip patterns are the same shape. Three of these are already in + * `VALUE_TAKING_TEST_FLAGS` — the guard knew them well enough to drop their + * values, but not that they shrink the run. + */ +export const testScriptNarrowingFlags = (scripts) => + testRunnerSegments(scripts) + .flat() + // `--flag=value` and `--flag value` both report as the bare flag name. + .map((t) => t.split("=")[0]) + .filter((t) => NARROWING_TEST_FLAGS.has(t)); + /** * Gate-integrity problems with `test:scripts` — this guard's OWN parser tests — * given the root `scripts` and the tracked `scripts/**` test files. Vouched for @@ -179,6 +208,10 @@ export function testScriptProblems(scripts, testFiles) { ); return problems; } + for (const flag of testScriptNarrowingFlags(scripts)) + problems.push( + `\`test:scripts\` passes \`${flag}\` — \`node --test\` then runs only part of the matched suite and still exits 0, so the parsers this guard gates are partly unrun. Drop it.`, + ); for (const f of testFiles) if (!globs.some((g) => matchesTestGlob(f, g))) problems.push( @@ -578,6 +611,25 @@ function trackedNonClientSource() { .filter((f) => !f.startsWith("clients/")); } +/** + * The remediation footer for a set of gate-integrity problems, or `null` when + * none of them is about the typecheck wiring. Not every phase-1 problem is: the + * sibling-guard vouch (`verify:format-coverage`), client enrollment (a + * manifest-less `clients/*`, a stale `EXEMPT` key), and the `test:scripts` axes + * each carry their own per-line remediation, and appending `--noCheck`/`tsc -b` + * advice under one of those sends the reader after the wrong thing. + * + * Pure and outside `main()` on purpose — this is the third consecutive round in + * which the newest branch in `main()` turned out to be the one mutation testing + * couldn't reach, including the branch this function replaces. + */ +export function integrityAdvice(problems, nonTypecheckProblems) { + const isTypecheck = (f) => !nonTypecheckProblems.includes(f); + return problems.some(isTypecheck) + ? "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass." + : null; +} + /** * Run the guard: enumerate clients, check gate integrity (phase 1), then file * coverage (phase 2). Prints its verdict and `process.exit(1)`s on any failure. @@ -614,11 +666,13 @@ export function main() { // Vouch for the sibling guard — a guard can't detect being unrun itself, but the // two can each assert the other is still wired into `validate`, so dropping // either is caught here (only deleting both slips through). + const siblingVouchIssues = []; if (!rootReachesScript(rootPkg.scripts, "verify:format-coverage")) { - integrity.push( + siblingVouchIssues.push( "the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.", ); } + integrity.push(...siblingVouchIssues); const testScriptIssues = testScriptProblems( rootPkg.scripts, @@ -698,14 +752,12 @@ export function main() { `verify:typecheck-coverage — ${integrity.length} gate-integrity issue(s): a typecheck gate is not run, or runs but checks nothing:\n`, ); for (const f of integrity) console.error(" " + f); - // Only advise on the typecheck wiring when something above is ABOUT it — a - // `test:scripts` issue carries its own remediation per line, and appending - // `--noCheck`/`tsc -b` advice under a renamed-test-file message sends the - // reader after the wrong thing. - if (integrity.some((f) => !testScriptIssues.includes(f))) - console.error( - "\nRestore the `typecheck` wiring (client `validate` → `typecheck`, root `validate` → each client), and drop any `--noCheck`/`--listFilesOnly`/`noCheck` from the typecheck pass.", - ); + const advice = integrityAdvice(integrity, [ + ...enrollmentProblems, + ...siblingVouchIssues, + ...testScriptIssues, + ]); + if (advice) console.error(advice); process.exit(1); } diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 4bae687d8..80a310252 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -13,7 +13,9 @@ import { parseTsconfigReferences, projectConfigFile, refToProject, + integrityAdvice, testScriptGlobs, + testScriptNarrowingFlags, testScriptProblems, tscBuildStatus, typecheckProjects, @@ -276,6 +278,72 @@ test("testScriptProblems: all three axes (r33 finding 2)", () => { ); }); +test("testScriptNarrowingFlags: flags that shrink the run (r35 finding 1)", () => { + const flags = (cmd) => testScriptNarrowingFlags({ "test:scripts": cmd }); + const GLOB = '"scripts/**/*.test.mjs"'; + // `--test-shard 1/2` keeps every glob intact and runs half the suite, exit 0. + assert.deepEqual(flags(`node --test --test-shard 1/2 ${GLOB}`), [ + "--test-shard", + ]); + assert.deepEqual(flags(`node --test --test-only ${GLOB}`), ["--test-only"]); + assert.deepEqual(flags(`node --test --test-name-pattern zzz ${GLOB}`), [ + "--test-name-pattern", + ]); + assert.deepEqual(flags(`node --test --test-skip-pattern=zzz ${GLOB}`), [ + "--test-skip-pattern", + ]); // `=` form + // Not narrowing: the plain form, and a coverage flag that only reports. + assert.deepEqual(flags(`node --test ${GLOB}`), []); + assert.deepEqual( + flags( + `node --test --experimental-test-coverage --test-coverage-include "scripts/**/*.mjs" ${GLOB}`, + ), + [], + ); + // A narrowing flag on a NON-runner segment isn't the runner's (r33 gate). + assert.deepEqual( + testScriptNarrowingFlags({ + "test:scripts": `node --test ${GLOB}`, + "pretest:scripts": "some-tool --test-only", + }), + [], + ); + // Reported as a gate-integrity problem, with the globs still harvested. + const wired = { + validate: "npm run test:scripts", + "test:scripts": `node --test --test-shard 1/2 ${GLOB}`, + }; + assert.deepEqual(testScriptGlobs(wired), ["scripts/**/*.test.mjs"]); + const problems = testScriptProblems(wired, ["scripts/a.test.mjs"]); + assert.equal(problems.length, 1); + assert.match( + problems[0], + /--test-shard.*runs only part of the matched suite/, + ); +}); + +test("integrityAdvice: typecheck footer only for typecheck issues (r35 finding 2 / nit 3)", () => { + const TYPECHECK = "clients/cli: the root `validate` chain no longer runs …"; + const SIBLING = + "the root `validate` no longer runs `verify:format-coverage` …"; + const TESTS = "scripts/a.spec.mjs: not matched by the `test:scripts` glob …"; + // Only typecheck-wiring issues → advise. + assert.match( + integrityAdvice([TYPECHECK], []), + /Restore the `typecheck` wiring/, + ); + // Every issue is a non-typecheck one → stay silent. The sibling-guard vouch + // and client-enrollment problems count here too, not just `test:scripts`. + assert.equal(integrityAdvice([TESTS], [TESTS]), null); + assert.equal(integrityAdvice([SIBLING], [SIBLING]), null); + assert.equal(integrityAdvice([SIBLING, TESTS], [SIBLING, TESTS]), null); + // A mix still advises — the typecheck issue is real. + assert.match( + integrityAdvice([SIBLING, TYPECHECK, TESTS], [SIBLING, TESTS]), + /Restore the `typecheck` wiring/, + ); +}); + test("projectConfigFile: directory-form entry means /tsconfig.json (r26)", () => { assert.equal( projectConfigFile("clients/cli", "tsconfig.test.json"), From 746908a4981d6eeeb356d02069ca5a7793503af6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 11:06:49 -0400 Subject: [PATCH 37/39] =?UTF-8?q?chore(scripts):=20round-36=20=E2=80=94=20?= =?UTF-8?q?discover=20test=20files=20by=20content,=20not=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. False pass on the axis underneath the other four. The required test-file set was built with the same name pattern the `test:scripts` glob uses, so a rename escaping BOTH was invisible: the file left `testFiles` entirely, so nothing asked whether the runner was told about it, and the survivor satisfied the non-empty axis. A dot→hyphen slip (`npm-scripts.test.mjs` → `npm-scripts-test.mjs`) dropped 6 of 24 tests with every gate green — and the file that stops running is the one pinning the r16 prefix-sibling and r20 tail anchors, the series' two other false passes. Round 30 fixed "renamed to a form the glob misses" by broadening the name pattern; a fifth name pattern only moves the boundary again. `node --test` decides what a test is by whether it registers tests, so `isScriptsTestFile` reads that instead — the same measure-don't-model move as r25 and r32. It also covers what no name pattern can: a NEW test file added under a name the glob misses, never run and never noticed. Nit 2: `enrollmentProblems` no longer suppresses the footer. "declares no `typecheck` script …" IS about the typecheck pass and the footer's first clause is its canonical fix; excluding it lost the advice for the r15 rename scenario. The criterion is "is this about the typecheck pass at all", not "does it carry per-line advice" — several typecheck problems carry their own and still want the footer. JSDoc corrected accordingly. Nit 3: the complementary-shard boundary (`1/2` + `2/2` across two reachable scripts genuinely runs everything, and is flagged anyway) recorded on the helper, alongside the guard's other documented boundaries. Nit 4: the three `--test-coverage-{lines,branches,functions}` thresholds added to VALUE_TAKING_TEST_FLAGS. Their values are numeric and so inert rather than suppressing, but model completeness is cheaper than re-deriving that later. Verified. The hyphen rename now exits 1 naming the file; the `.spec.mjs` rename is still caught; an enrollment failure prints the footer again; the guard is unchanged at 894 files OK (exactly the 2 tracked test files reference `node:test`, none of the other 12 `scripts/` files do). Mutation sweep 14/14. `test:scripts` 25/25 · `verify:format-coverage` OK 919 · `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 46 +++++++++++++++++++--- scripts/verify-typecheck-coverage.test.mjs | 30 ++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index bd3b9ff78..ae8a6e959 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -109,6 +109,11 @@ const VALUE_TAKING_TEST_FLAGS = new Set([ "--test-name-pattern", "--test-skip-pattern", "--test-shard", + // Threshold flags: numeric values, so harvesting one is inert rather than a + // suppression (a number can't be a BROADER glob) — listed for completeness. + "--test-coverage-lines", + "--test-coverage-branches", + "--test-coverage-functions", ]); /** @@ -167,6 +172,11 @@ export const testScriptGlobs = (scripts) => * and the name/skip patterns are the same shape. Three of these are already in * `VALUE_TAKING_TEST_FLAGS` — the guard knew them well enough to drop their * values, but not that they shrink the run. + * + * Known boundary: COMPLEMENTARY shards (`--test-shard 1/2` and `2/2` in two + * reachable scripts) do run the whole suite, and this flags them anyway. Fail- + * safe, and nobody shards a sub-100ms suite; reconstructing shard arithmetic to + * allow it would cost more than the case is worth. */ export const testScriptNarrowingFlags = (scripts) => testRunnerSegments(scripts) @@ -175,6 +185,23 @@ export const testScriptNarrowingFlags = (scripts) => .map((t) => t.split("=")[0]) .filter((t) => NARROWING_TEST_FLAGS.has(t)); +/** + * Whether a tracked `scripts/` file is one of this guard's own test files, by + * CONTENT rather than name: a JS file that registers tests with `node:test` is + * a test whatever it's called. + * + * Deciding it by name would key the required set off the same pattern the + * `test:scripts` glob uses, so a rename escaping BOTH is invisible to every axis + * — a dot→hyphen slip (`npm-scripts.test.mjs` → `npm-scripts-test.mjs`) drops + * the file from `testFiles` entirely, so nothing asks whether the runner is told + * about it, and the surviving file satisfies the non-empty axis. Round 30 fixed + * "renamed to a form the GLOB misses" by broadening the name pattern; a fifth + * name pattern only moves that boundary again. `node --test` decides what a test + * is by whether it registers tests, so read that instead of guessing. + */ +export const isScriptsTestFile = (file, source) => + /\.(c|m)?js$/.test(file) && /["']node:test["']/.test(source); + /** * Gate-integrity problems with `test:scripts` — this guard's OWN parser tests — * given the root `scripts` and the tracked `scripts/**` test files. Vouched for @@ -614,10 +641,13 @@ function trackedNonClientSource() { /** * The remediation footer for a set of gate-integrity problems, or `null` when * none of them is about the typecheck wiring. Not every phase-1 problem is: the - * sibling-guard vouch (`verify:format-coverage`), client enrollment (a - * manifest-less `clients/*`, a stale `EXEMPT` key), and the `test:scripts` axes - * each carry their own per-line remediation, and appending `--noCheck`/`tsc -b` - * advice under one of those sends the reader after the wrong thing. + * sibling-guard vouch (`verify:format-coverage`) and the `test:scripts` axes are + * about OTHER gates, and appending `--noCheck`/`tsc -b` advice under one of them + * sends the reader after the wrong thing. The criterion is "is this about the + * typecheck pass at all", not "does it carry its own per-line advice" — several + * typecheck problems do carry their own and still want the footer. Client + * enrollment ("declares no `typecheck` script …") is therefore NOT excluded: the + * footer's first clause is exactly its fix. * * Pure and outside `main()` on purpose — this is the third consecutive round in * which the newest branch in `main()` turned out to be the one mutation testing @@ -681,7 +711,10 @@ export function main() { encoding: "utf8", }) .split("\n") - .filter((f) => /\.(test|spec)\.[^/]+$/.test(f)), + .filter(Boolean) + .filter((f) => + isScriptsTestFile(f, readFileSync(path.join(repoRoot, f), "utf8")), + ), ); integrity.push(...testScriptIssues); @@ -752,8 +785,9 @@ export function main() { `verify:typecheck-coverage — ${integrity.length} gate-integrity issue(s): a typecheck gate is not run, or runs but checks nothing:\n`, ); for (const f of integrity) console.error(" " + f); + // NOT `enrollmentProblems`: "declares no `typecheck` script …" IS about the + // typecheck pass, and the footer's first clause is its canonical fix. const advice = integrityAdvice(integrity, [ - ...enrollmentProblems, ...siblingVouchIssues, ...testScriptIssues, ]); diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 80a310252..9bfc8a4c5 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -14,6 +14,7 @@ import { projectConfigFile, refToProject, integrityAdvice, + isScriptsTestFile, testScriptGlobs, testScriptNarrowingFlags, testScriptProblems, @@ -278,6 +279,27 @@ test("testScriptProblems: all three axes (r33 finding 2)", () => { ); }); +test("isScriptsTestFile: discovery by content, not name (r36 finding 1)", () => { + const TEST_SRC = 'import { test } from "node:test";\ntest("x", () => {});\n'; + // The name is irrelevant — a dot→hyphen rename escapes every name pattern, + // and that is exactly the rename that silently dropped 6 of 24 tests. + assert.ok(isScriptsTestFile("scripts/lib/npm-scripts.test.mjs", TEST_SRC)); + assert.ok(isScriptsTestFile("scripts/lib/npm-scripts-test.mjs", TEST_SRC)); + assert.ok(isScriptsTestFile("scripts/tokenize-tests.mjs", TEST_SRC)); + assert.ok(isScriptsTestFile("scripts/a.test.js", TEST_SRC)); + assert.ok(isScriptsTestFile("scripts/a.test.cjs", "require('node:test')")); + // A guard script that merely mentions testing is not a test. + assert.ok( + !isScriptsTestFile( + "scripts/verify-typecheck-coverage.mjs", + "// runs the test:scripts glob\nimport path from 'node:path';\n", + ), + ); + // Non-JS never counts, whatever it contains. + assert.ok(!isScriptsTestFile("scripts/notes.md", TEST_SRC)); + assert.ok(!isScriptsTestFile("scripts/a.test.ts", TEST_SRC)); +}); + test("testScriptNarrowingFlags: flags that shrink the run (r35 finding 1)", () => { const flags = (cmd) => testScriptNarrowingFlags({ "test:scripts": cmd }); const GLOB = '"scripts/**/*.test.mjs"'; @@ -337,6 +359,14 @@ test("integrityAdvice: typecheck footer only for typecheck issues (r35 finding 2 assert.equal(integrityAdvice([TESTS], [TESTS]), null); assert.equal(integrityAdvice([SIBLING], [SIBLING]), null); assert.equal(integrityAdvice([SIBLING, TESTS], [SIBLING, TESTS]), null); + // Client enrollment ("declares no `typecheck` script …") IS about the + // typecheck pass — the footer's first clause is its fix — so it advises. + const ENROLLMENT = + "clients/tui: declares no `typecheck` script, has no `tsconfig.json` `references` …"; + assert.match( + integrityAdvice([ENROLLMENT], [SIBLING, TESTS]), + /Restore the `typecheck` wiring/, + ); // A mix still advises — the typecheck issue is real. assert.match( integrityAdvice([SIBLING, TYPECHECK, TESTS], [SIBLING, TESTS]), From b53a0fdd10057898bec3e30cdf65c0ab0206605c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 11:21:55 -0400 Subject: [PATCH 38/39] =?UTF-8?q?chore(scripts):=20round-37=20=E2=80=94=20?= =?UTF-8?q?register-not-mention;=20guard=20the=20discovery=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings were introduced by round 36's content predicate. 1. False failure. The predicate matched a bare `node:test` mention, so a shared helper importing the runner's `mock` API — registering no tests — was enrolled as a required test file and told to rename itself to `*.test.mjs`, which would only make node run an empty file. `scripts/lib/` is already the repo's shared-helper location, so this is the plausible next file there. Tightened to node's actual criterion: an import/require OF `node:test` AND a test registration. 2. Regression. `readFileSync` was passed as an argument, so it ran for every tracked `scripts/` file before the predicate's extension gate could reject it — a file in the index but not the worktree (`rm` without `git rm`) took the guard down with a raw ENOENT stack, aborting phase 1 before any real finding printed. Discovery did no reads at all before round 36. Extension is now gated first and the read wrapped: an unreadable tracked file warns and is skipped, the round-6 precedent where a failed `tsc` echoes its diagnostic instead of dying. Verified: the mock-helper probe exits 0; the tracked-but-deleted probe exits 0 with a one-line skip warning instead of a stack; the dot→hyphen rename is still caught. Mutation sweep over the predicate 3/3 (import form, registration, extension). `test:scripts` 25/25 · `verify:typecheck-coverage` OK 894 · `verify:format-coverage` OK 919 · `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 28 ++++++++++++++++++---- scripts/verify-typecheck-coverage.test.mjs | 15 +++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index ae8a6e959..67490510f 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -200,7 +200,12 @@ export const testScriptNarrowingFlags = (scripts) => * is by whether it registers tests, so read that instead of guessing. */ export const isScriptsTestFile = (file, source) => - /\.(c|m)?js$/.test(file) && /["']node:test["']/.test(source); + /\.(c|m)?js$/.test(file) && + // Node's actual criterion is REGISTERING tests, not mentioning the module: a + // shared helper importing `node:test`'s `mock` registers none, and telling it + // to rename itself to `*.test.mjs` would only make node run an empty file. + /(?:\bfrom|\brequire\(\s*)\s*["']node:test["']/.test(source) && + /\b(?:test|it|describe|suite)\s*\(/.test(source); /** * Gate-integrity problems with `test:scripts` — this guard's OWN parser tests — @@ -712,9 +717,24 @@ export function main() { }) .split("\n") .filter(Boolean) - .filter((f) => - isScriptsTestFile(f, readFileSync(path.join(repoRoot, f), "utf8")), - ), + // Extension first, and the read guarded: a file in the index but not the + // worktree (`rm` without `git rm`, an ordinary mid-work state) would + // otherwise abort phase 1 with a raw ENOENT stack, masking every real + // finding — the round-6 precedent, where a failed `tsc` echoes its + // diagnostic instead of dying. + .filter((f) => /\.(c|m)?js$/.test(f)) + .filter((f) => { + let src; + try { + src = readFileSync(path.join(repoRoot, f), "utf8"); + } catch (err) { + console.warn( + `verify:typecheck-coverage — skipping tracked-but-unreadable ${f}: ${err.message}`, + ); + return false; + } + return isScriptsTestFile(f, src); + }), ); integrity.push(...testScriptIssues); diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 9bfc8a4c5..22151c254 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -287,7 +287,20 @@ test("isScriptsTestFile: discovery by content, not name (r36 finding 1)", () => assert.ok(isScriptsTestFile("scripts/lib/npm-scripts-test.mjs", TEST_SRC)); assert.ok(isScriptsTestFile("scripts/tokenize-tests.mjs", TEST_SRC)); assert.ok(isScriptsTestFile("scripts/a.test.js", TEST_SRC)); - assert.ok(isScriptsTestFile("scripts/a.test.cjs", "require('node:test')")); + assert.ok( + isScriptsTestFile( + "scripts/a.test.cjs", + "const { test } = require('node:test');\ntest('x', () => {});\n", + ), + ); + // A shared helper importing `node:test`'s `mock` registers no tests — telling + // it to rename itself to `*.test.mjs` would make node run an empty file. + assert.ok( + !isScriptsTestFile( + "scripts/lib/test-mocks.mjs", + 'import { mock } from "node:test";\nexport const resetAll = () => mock.reset();\n', + ), + ); // A guard script that merely mentions testing is not a test. assert.ok( !isScriptsTestFile( From 09bb925433026a963737d4f7abf9e976de7d70b2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 27 Jul 2026 11:27:01 -0400 Subject: [PATCH 39/39] =?UTF-8?q?chore(scripts):=20round-37=20nit=203=20?= =?UTF-8?q?=E2=80=94=20one=20definition=20of=20the=20JS-extension=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 2's fix hoisted the extension gate into `main()` (it must run before the read), leaving a second copy of the regex inside the predicate that reads as protection but can never fire there. Extracted as `isJsFile` and used in both positions, so the two can't drift. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw --- scripts/verify-typecheck-coverage.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 67490510f..20d6ab2cc 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -199,8 +199,12 @@ export const testScriptNarrowingFlags = (scripts) => * name pattern only moves that boundary again. `node --test` decides what a test * is by whether it registers tests, so read that instead of guessing. */ +// The extension half, alone: `main()` needs it BEFORE reading a file, and the +// predicate needs it as part of its contract. One definition, two positions. +export const isJsFile = (file) => /\.(c|m)?js$/.test(file); + export const isScriptsTestFile = (file, source) => - /\.(c|m)?js$/.test(file) && + isJsFile(file) && // Node's actual criterion is REGISTERING tests, not mentioning the module: a // shared helper importing `node:test`'s `mock` registers none, and telling it // to rename itself to `*.test.mjs` would only make node run an empty file. @@ -722,7 +726,7 @@ export function main() { // otherwise abort phase 1 with a raw ENOENT stack, masking every real // finding — the round-6 precedent, where a failed `tsc` echoes its // diagnostic instead of dying. - .filter((f) => /\.(c|m)?js$/.test(f)) + .filter(isJsFile) .filter((f) => { let src; try {