mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-10-30 07:54:29 +08:00
- verify:format-coverage and verify:typecheck-coverage ported from the Inspector, with the Inspector's tsc-program and resolve-node-bin libs (+ tests), adapted to npm workspaces via a new lib/workspaces.mjs that reads the root workspaces patterns from disk and checks the root validate still runs `npm run validate --workspaces`. - verify:dep-lockstep: every shared toolchain devDependency (typescript, vitest, @vitest/coverage-v8, prettier, @types/node) is declared with one range everywhere, or once at the root. - Root validate = validate:guards (root format/lint, the guards, test:scripts) + npm run validate --workspaces. - typescript.yml: each matrix leg runs only its own package's validate; the root-guards job runs validate:guards. - Removes #4863's temporary verify:format-coverage wiring allowance, and fixes the four root-lint findings in the skills scripts. Refs #4864 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
670 lines
30 KiB
JavaScript
670 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
|
// Durable guard for the "every tracked TypeScript file gets a `tsc` pass"
|
|
// invariant (#4864, ported from the MCP Inspector, where it is inspector#1791;
|
|
// `rN` tags below are that repo's review rounds). A tsconfig project only
|
|
// typechecks the files its `include`/`files` name plus whatever those
|
|
// transitively import — so a new test, helper or config file can silently fall
|
|
// outside every project and get no type-checking. Three of this repo's four
|
|
// servers exclude their tests from the build `tsconfig.json`, which is exactly
|
|
// that hole; each workspace's `typecheck` runs `tsconfig.test.json` to close it,
|
|
// and this guard keeps it closed.
|
|
//
|
|
// For each npm workspace (read from the root `workspaces` patterns on disk, so
|
|
// a new server is required automatically) it runs every project the
|
|
// workspace's `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 workspace is
|
|
// in that union. Tracked TypeScript OUTSIDE every workspace (a new top-level
|
|
// location) is covered deny-by-default: it must land in the global union.
|
|
// Exits non-zero, listing the offenders, on any miss.
|
|
//
|
|
// Like its sibling it also asserts the gate is actually WIRED: each
|
|
// workspace's `typecheck` must be reachable from that workspace's `validate`,
|
|
// and the root `validate` chain must invoke each workspace's `validate` —
|
|
// otherwise the guard could measure a `typecheck` script nothing runs and stay
|
|
// green while nothing is typechecked ("gate silently stops gating").
|
|
//
|
|
// Source of truth is the `typecheck` scripts themselves — the 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. ("Client" in the helper names below is the Inspector's word for what
|
|
// is a workspace here.)
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import {
|
|
reachableScripts,
|
|
rootReachesScript,
|
|
tokenize,
|
|
} from "./lib/npm-scripts.mjs";
|
|
import {
|
|
readWorkspaces,
|
|
rootRunsWorkspaceValidate,
|
|
} from "./lib/workspaces.mjs";
|
|
// The `tsc --listFilesOnly` machinery lives in `lib/tsc-program.mjs`.
|
|
// `tscEntry` (the inspector#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)),
|
|
"..",
|
|
);
|
|
|
|
// Workspaces this guard deliberately does NOT gate, with the reason. The escape
|
|
// hatch for a genuinely-ungateable workspace. Empty, and meant to stay so: an
|
|
// exemption is wider than any gate it defers to.
|
|
const EXEMPT = new Map();
|
|
|
|
// TypeScript source extensions this guard requires a tsc pass for. Matches its
|
|
// sibling's TS set (`verify-format-coverage.mjs`), so a workspace `.mts`/`.cts`
|
|
// is gated too. Ambient declaration files are excluded: an unreferenced
|
|
// `*.d.{ts,mts,cts}` shim is type-only and not a real gap. There are no
|
|
// `.mts`/`.cts` files 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 workspaces this guard covers: every directory the root `workspaces`
|
|
* patterns match (`scripts/lib/workspaces.mjs`) 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, measured through those) — **or** be in
|
|
* {@link EXEMPT}. Anything else is a gate-integrity failure. Enumerated from
|
|
* **disk** so it is fail-closed: a new server is auto-required, renaming the
|
|
* `typecheck` script hard-fails rather than silently dropping the workspace,
|
|
* and a matched directory holding tracked TypeScript but no manifest (which npm
|
|
* would simply not treat as a workspace) hard-fails too. Returns
|
|
* `{ clients, problems }`.
|
|
*/
|
|
function nodeClients() {
|
|
const clients = [];
|
|
const problems = [];
|
|
let entries;
|
|
try {
|
|
entries = readWorkspaces(repoRoot);
|
|
} catch (err) {
|
|
problems.push(err.message);
|
|
return { clients, problems };
|
|
}
|
|
for (const { dir, hasManifest } of entries) {
|
|
if (EXEMPT.has(dir)) continue;
|
|
let scripts;
|
|
try {
|
|
if (!hasManifest) throw new Error("no package.json");
|
|
scripts = JSON.parse(
|
|
readFileSync(path.join(repoRoot, dir, "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 workspace (the
|
|
// Python servers share `src/*` with the TypeScript ones).
|
|
if (trackedSourceFiles(dir).length > 0)
|
|
problems.push(
|
|
`${dir}: holds tracked TypeScript but has no readable \`package.json\` — npm does not treat it as a workspace, so its source gets no tsc pass. Add a 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.map((e) => e.dir));
|
|
for (const dir of EXEMPT.keys())
|
|
if (!present.has(dir))
|
|
problems.push(
|
|
`${dir}: listed in EXEMPT but is not a workspace 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 workspace (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 any workspace dir (which the
|
|
* per-workspace pass covers) — anything at a new top-level location, and any
|
|
* root-level config written in TypeScript. Deny-by-default: such a file is
|
|
* required without editing this guard, matching the repo-wide reach of the
|
|
* sibling `verify-format-coverage`, and it must get a tsc pass from SOME
|
|
* workspace project (checked against the GLOBAL union). The root tooling under
|
|
* `scripts/` is `.mjs`, which is not in the required set.
|
|
*/
|
|
function trackedNonClientSource(workspaceDirs) {
|
|
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) => !workspaceDirs.some((d) => f.startsWith(`${d}/`)));
|
|
}
|
|
|
|
/**
|
|
* 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 (workspace `validate` → `typecheck`, root `validate` → `npm run validate --workspaces`), 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 `src/` 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 workspace 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 (!rootRunsWorkspaceValidate(rootPkg.scripts, clientDir)) {
|
|
integrity.push(
|
|
`${clientDir}: the root \`validate\` chain no longer runs its \`validate\` (expected \`npm run validate --workspaces\`) — its typecheck isn't invoked by the gate.`,
|
|
);
|
|
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`);
|
|
}
|
|
|
|
// First-party TS outside every workspace gets no workspace of its own;
|
|
// require each to land in the GLOBAL union of workspace projects, so it's
|
|
// deny-by-default rather than an allowlist that a new location could fall
|
|
// outside.
|
|
const otherTracked = trackedNonClientSource(CLIENTS);
|
|
totalChecked += otherTracked.length;
|
|
const nonClientMisses = [];
|
|
for (const f of otherTracked)
|
|
if (!globalCovered.has(f)) {
|
|
nonClientMisses.push(f);
|
|
failures.push(`${f} — in no workspace'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 its workspace's `tsconfig.test.json` `include` (the program `typecheck` runs). Keep a test out of the build `tsconfig.json`, or the build emits it into `dist/`.",
|
|
);
|
|
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:root`, and `.mjs` isn't in this guard's required set).",
|
|
);
|
|
else if (nonClientMisses.length > 0)
|
|
console.error(
|
|
"A TypeScript file outside every workspace needs a workspace project that includes it, or a move into the workspace that owns it.",
|
|
);
|
|
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} workspaces + non-workspace) 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();
|