* 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>
896 lines
41 KiB
JavaScript
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();
|