Files
cliffhallandClaude Opus 5.5 a9cf19e162 feat: coverage and lockstep guards, root validate, per-package CI legs
- 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>
2026-09-29 00:10:03 -04:00

313 lines
13 KiB
JavaScript

// What a `tsc` project actually pulls into its program — the measurement
// `verify:typecheck-coverage` reads a workspace's programs through.
//
// Ported from the MCP Inspector for #4864; `#N` in these comments are that
// repo's issues. The Inspector shares this module with a program-level
// dependency guard that also asks which INSTALLED packages land in a program;
// this repo's dependency guard (`verify-dep-lockstep.mjs`) compares declared
// ranges instead, so that half was not ported. "Client" in the helpers' names
// and comments is the Inspector's word for what is a workspace (`src/<name>`)
// here: the unit that owns its own `package.json` and tsconfig projects.
//
// The answer comes from `tsc --listFilesOnly`: it reports every file the
// program resolves, including the ones reached only through another file's
// imports. That is the accurate measure — a tsconfig's `include` names only
// the roots.
//
// The listing is memoized per (workspace, project) for the life of the
// process. It is deliberately NOT cached to disk: a stale cache would make the
// guard measure a program that no longer exists and pass on a real miss —
// failing open, the one way a gate must never fail.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { reachableScripts, tokenize } from "./npm-scripts.mjs";
import { resolveNodeBin } from "./resolve-node-bin.mjs";
export const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
);
// 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.
export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t);
/**
* 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 typecheck guard
* while checking nothing.
*
* A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists
* nothing itself; {@link resolveLeafProjects} expands it to its references.
*
* 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 };
}
/**
* The `references` paths declared in a tsconfig's raw text (a `tsc -b` solution
* config), or `[]` if it has none / isn't parseable. 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 workspace's root `tsconfig.json`. Non-empty for a
* `tsc -b` solution config — the guard enrolls such a workspace through these
* instead of exempting the whole tree. No workspace here is one today; kept so
* that adopting project references does not silently drop a workspace.
*/
export function clientTsconfigReferences(clientDir) {
return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json"));
}
/**
* 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 inside a single
* caller) is what lets every consumer follow the same graph.
*/
export function resolveLeafProjects(clientDir, project, seen = new Set()) {
if (seen.has(project)) return [];
seen.add(project);
// Lists first-party 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 either.)
if (projectSourceFiles(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,
),
);
}
/**
* The tsc JS entry a client's programs are measured with, resolved from the
* client dir up the node_modules tree exactly as `npx --no-install tsc` walked
* — but spawnable shell-free on Windows, where `npx` is a `.cmd` shim that
* `execFileSync` can't start (ENOENT — #1939). A resolution failure is a hard
* "cannot measure" error rather than an empty file set: the old ENOENT was
* doubly silent, echoing "(no diagnostic captured)" per project and then
* reporting every tracked file in the repo as uncovered. Cached per client;
* exported so a consumer's own tsc pass (`verify-typecheck-coverage`'s
* `--showConfig`) spawns the same entry the listings do.
*/
const tscEntryCache = new Map();
export function tscEntry(clientDir) {
const cached = tscEntryCache.get(clientDir);
if (cached) return cached;
let entry;
try {
entry = resolveNodeBin("typescript", "tsc", path.join(repoRoot, clientDir));
} catch (err) {
console.error(
`cannot resolve \`typescript\` from ${clientDir} (${err.message}): no tsc program can be measured. Run \`npm install\` at the repo root first.`,
);
process.exit(1);
}
tscEntryCache.set(clientDir, entry);
return entry;
}
/**
* Every file ONE project's program resolves, as absolute POSIX paths and with no
* filtering at all — first-party sources, `node_modules` declarations, and the
* out-of-repo `lib.*.d.ts` — plus the config diagnostic if `tsc` exited
* non-zero. {@link projectSourceFiles} slices the first-party files out of it.
* Memoized per (client, project): the same project is listed
* by `resolveLeafProjects` and again by whichever slice the caller asks for.
*/
const listingCache = new Map();
export function rawProjectListing(clientDir, project) {
const key = `${clientDir}|${project}`;
const cached = listingCache.get(key);
if (cached) return cached;
let stdout;
let error = null;
try {
stdout = execFileSync(
process.execPath,
[tscEntry(clientDir), "-p", project, "--listFilesOnly"],
{
cwd: path.join(repoRoot, clientDir),
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1 << 28,
},
);
} 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 what the callers measure.
// Echo the diagnostic — the guards run before any client's own `typecheck`,
// so this is the first place a bad `-p` config surfaces, and without the
// reason the resulting 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 : "");
error = streams
.split("\n")
.filter((l) => /error TS\d+/.test(l))
.join("\n")
.trim();
console.warn(
`tsc -p ${project} (in ${clientDir}) exited non-zero:\n${error || "(no diagnostic captured)"}\n`,
);
// An empty diagnostic still has to read as failure downstream, so the
// caller sees a string either way — never `""`.
error ||= "(no diagnostic captured)";
}
const files = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((abs) => abs.split(path.sep).join("/"));
const listing = { files, error };
listingCache.set(key, listing);
return listing;
}
/**
* The `tsc` diagnostic for a project whose listing exited non-zero, or null. A
* failed config still prints a file list — a partial, wrong one — so a consumer
* that must not measure a program it couldn't resolve has to ask.
*/
export function projectListingError(clientDir, project) {
return rawProjectListing(clientDir, project).error;
}
/**
* Whether a repo-relative path is an installed file rather than a first-party
* one. Matched by path SEGMENT, not substring, so a first-party directory whose
* name merely embeds the word (`src/x/node_modules_fixtures/`) is not mistaken
* for an install.
*/
const isInstalledPath = (rel) => rel.split("/").includes("node_modules");
/**
* Repo-relative POSIX paths of the first-party files ONE project (no reference
* expansion) typechecks. Absolute paths outside the repo root (`lib.d.ts`) and
* anything under `node_modules` are dropped.
*/
export function projectSourceFiles(clientDir, project) {
const covered = new Set();
for (const abs of rawProjectListing(clientDir, project).files) {
const rel = path.relative(repoRoot, abs).split(path.sep).join("/");
if (rel.startsWith("..") || isInstalledPath(rel)) continue;
covered.add(rel);
}
return covered;
}