inspector/scripts/verify-typecheck-coverage.mjs
olaservo 7164806be3 Merge origin/v2/main: move the #1939 npx fix into the shared tsc-program lib
Upstream #1965 moved the tsc --listFilesOnly machinery into
scripts/lib/tsc-program.mjs (shared with the new verify:dep-lockstep),
still spawning `npx` — which is ENOENT on Windows. Resolve the conflict
by taking upstream's factoring and porting the #1939 fix into the lib:
rawProjectListing and verify-typecheck-coverage's --showConfig pass now
spawn process.execPath with the cached, hard-failing tscEntry resolver
(exported from tsc-program.mjs). Fixture copies in both main tests pick
up the new lib import graph. verify:dep-lockstep now runs on Windows
too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:07:50 -07:00

694 lines
32 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";
// The `tsc --listFilesOnly` machinery lives in `lib/` because
// `verify-dep-lockstep` measures the same programs for a different question
// (#1965) — one implementation, so the two guards can't disagree about what a
// program contains. `tscEntry` (the #1939 Windows-safe tsc resolver) comes from
// the same module so the `--showConfig` pass below spawns tsc the same way the
// listings do.
import {
clientTsconfigReferences,
isDisablingFlag,
isTsc,
projectSourceFiles,
resolveLeafProjects,
tscEntry,
typecheckProjects,
} from "./lib/tsc-program.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);
/**
* 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;
}
/**
* 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 };
}
/**
* 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(
process.execPath,
[tscEntry(clientDir), "-p", project, "--showConfig"],
{ cwd: path.join(repoRoot, clientDir), encoding: "utf8" },
);
return JSON.parse(out)?.compilerOptions?.noCheck === true;
} catch {
return false;
}
}
/** 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 projectSourceFiles(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();