inspector/scripts/verify-typecheck-coverage.mjs
Cliff Hall d9e4f444a2
test(cli,tui): typecheck the __tests__ dirs (#1791) (#1799)
* test(cli,tui): typecheck the __tests__ dirs (#1791)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(cli,tui): address review — real spy signatures, config typecheck, nits

Responds to the @claude review of #1799:

- App.test.tsx: type the OAuth spies against the real InspectorClient method
  signatures (`vi.fn<InspectorClient["…"]>()`) 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-2 review — correct claims, tighten types, typecheck launcher

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-3 nits — launcher config typecheck, callTool typed, docs

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<void>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-4 — durable typecheck-coverage guard + launcher/doc nits

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: address round-5 — wire-check the guard, --listFilesOnly, into validate

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): address round-6 guard nits — dedupe wiring, robust diagnostics

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-7 — guard .mts/.cts too; drop dead export

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-8 — single casts in HistoryTab, uniform wrappers, doc scope

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<InspectorClient["…"]>)` (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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-9 — drop entry()'s harmful cast; guards vouch for each other

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<MessageEntry>
  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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-10 — reject non-checking typecheck passes; pre/post hooks; docs

Responds to the round-10 @claude review of #1799:

- Finding 1: verify-typecheck-coverage now rejects a `-p <project>` 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<name>`/`post<name>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* fix: round-11 — launcher build excludes tests; guard catches all noCheck vectors

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* fix: round-12 — exclude .mts/.cts tests from build; drop false integrity line

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 <project>`" 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-13 — implicit-tsconfig + derived clients; diagnostics

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/<x> && 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-14 — enumerate clients from disk (fail-closed)

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/<name>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-15 — enroll-or-exempt enumeration; normalize `cd ./`

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-16 — anchor wiring match; close enumeration axes

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-17 — share quote-aware tokenizer between guards

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-18 — match tsc by basename; strip quotes on both halves

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: round-19 — typecheck test-servers' unimported bin; --prefix wiring form

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 <dir> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-20 — anchor validate tail; guard shared-source class

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-21 — deny-by-default non-client coverage

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-22 — narrow core/ exemption to what web's tsc -b covers

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-23 — core exemption mirrors both web projects

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-24 — enroll clients/web via tsconfig references

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 <ref> --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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-25 — drop redundant core exemption; guard tsc -b --noCheck

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-26 — expand solution configs wherever harvested

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-27 — non-inertness follows references on both paths

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
  `<dir>/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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-28 — dedup shared leaf in checkingLeaves

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(scripts): unit-test the typecheck-coverage guard's parsers

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test(scripts): make test:scripts self-guarding; fill parser test gaps

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore(scripts): round-31 — glob harvest follows delegation; brace/? globs

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-32 — measure globs with node's own matcher

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`/`<name>`), 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-33 — scope the glob harvest to `node --test` segments

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-34 — harvest only positional args; normalize `./`

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-35 — fourth axis: flags that narrow the run

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-36 — discover test files by content, not name

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-37 — register-not-mention; guard the discovery read

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

* chore(scripts): round-37 nit 3 — one definition of the JS-extension check

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vgJ6ZYj9VLhfSjgua48zw

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 12:05:35 -04:00

896 lines
41 KiB
JavaScript

#!/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 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
// 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
// --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 — 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
// `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`/`--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 { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
reachableScripts,
rootReachesScript,
rootRunsClientValidate,
tokenize,
} from "./lib/npm-scripts.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
// 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
// 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.
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.
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.
export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t);
/**
* 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);
// `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",
"--test-coverage-exclude",
"--test-reporter",
"--test-reporter-destination",
"--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",
]);
/**
* 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. Reachability also picks up a
* `pretest:scripts` hook for free.
*
* 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.
*
* 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).
*/
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
export const testScriptGlobs = (scripts) =>
testRunnerSegments(scripts)
.flatMap((tokens) =>
tokens.filter(
(t, i) =>
!t.startsWith("-") &&
t !== "node" &&
!VALUE_TAKING_TEST_FLAGS.has(tokens[i - 1]),
),
)
.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.
*
* 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)
.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));
/**
* 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.
*/
// 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) =>
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.
/(?:\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 —
* 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 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(
`${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
* are as written (relative to that tsconfig's own directory).
*/
export function parseTsconfigReferences(raw) {
try {
// 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(/\/\*[\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")
: [];
} catch {
return [];
}
}
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
* enrolls it through these instead of exempting the whole tree.
*/
export 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
* `--noCheck`/`--listFilesOnly` — lists files but checks nothing, the same hole
* the `typecheck`-script path rejects), or `"none"` (no `tsc -b` at all).
*/
export 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)) 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 status;
}
/**
* The Node clients this guard covers (#1791): every `clients/<name>` dir with a
* 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");
const clients = [];
const problems = [];
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, entry.name, "package.json"), "utf8"),
).scripts;
} catch {
// 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 (
typeof scripts?.typecheck === "string" ||
clientTsconfigReferences(dir).length > 0
)
clients.push(dir);
else
problems.push(
`${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
// 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 };
}
/**
* 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}.
*
* 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
* <file>` 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).
*/
export 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(/&&|\|\||;/)) {
const tokens = tokenize(segment);
if (!tokens.some(isTsc)) continue; // only tsc commands name projects
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 = [];
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 });
else projects.push(project);
}
}
}
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 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.
*/
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 {
stdout = execFileSync(
"npx",
["--no-install", "tsc", "-p", project, "--listFilesOnly"],
{ cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
} 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. 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")) {
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("/"));
}
rawFilesCache.set(key, covered);
return covered;
}
/**
* The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names.
* A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means
* `<dir>/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),
);
}
/**
* 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);
// 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];
const configFile = projectConfigFile(clientDir, project);
const refs = tsconfigReferences(configFile);
if (refs.length === 0) return [project]; // no files, no refs — itself
return refs.flatMap((ref) =>
resolveLeafProjects(
clientDir,
refToProject(clientDir, configFile, 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 = [];
// 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 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(
"git",
["ls-files", "*.ts", "*.tsx", "*.mts", "*.cts"],
{ 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/"));
}
/**
* 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(
"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.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`) 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
* 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.
* 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"),
);
const { clients: CLIENTS, problems: enrollmentProblems } = nodeClients();
// -------------------------------------------------------------------------
// 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);
}
// 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")) {
siblingVouchIssues.push(
"the root `validate` no longer runs `verify:format-coverage` (its sibling guard) — restore it.",
);
}
integrity.push(...siblingVouchIssues);
const testScriptIssues = testScriptProblems(
rootPkg.scripts,
execFileSync("git", ["ls-files", "scripts"], {
cwd: repoRoot,
encoding: "utf8",
})
.split("\n")
.filter(Boolean)
// 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(isJsFile)
.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);
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(
`${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 <project>\` — 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, 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);
// 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, [
...siblingVouchIssues,
...testScriptIssues,
]);
if (advice) console.error(advice);
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);
}
const tracked = trackedSourceFiles(clientDir);
totalChecked += tracked.length;
for (const f of tracked)
if (!covered.has(f)) failures.push(`${f} — in no tsconfig project`);
}
// 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(
`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/")))
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}).` : "."),
);
}
// 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();