`verify:dep-lockstep` derived its candidate set from the packages the shared first-party sources named *directly*, so a package whose declarations reach a program only through another package's `.d.ts` was invisible to it — it could skew across installs and recreate the two-copy blow-up #1896 was about while the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never written in first-party code, yet 16 of its `.d.ts` files land in clients/web's test program. Replace the derivation with the measured one: list every client tsconfig project with `tsc --listFilesOnly`, map each resolved `node_modules` file to its owning install and package, and keep the packages that reach ONE program from TWO installs. That is exactly the set that can put two structurally distinct copies of a type in front of one checker. The rest of the guard is unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as a name allowlist scoped to within-a-major. The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with `verify:typecheck-coverage`, so the two guards can't disagree about what a program contains. It is deliberately not cached to disk between the two processes: a stale fingerprint would make a guard measure a program that no longer exists and pass on a real miss. The second pass costs ~14s. `TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are root-only since #1970 and cannot skew; `react` (whose types are `@types/react`, resolved from one install) and `hono` never reach one program from two installs. Each would be a rationale for a skew that cannot occur. Closes #1965 Signed-off-by: cliffhall <cliff@futurescale.com>
382 lines
17 KiB
JavaScript
382 lines
17 KiB
JavaScript
// What a `tsc` project actually pulls into its program — the shared measurement
|
|
// behind two guards, so the two can never disagree about what a program holds.
|
|
//
|
|
// • `verify:typecheck-coverage` (#1791) asks which FIRST-PARTY files land in a
|
|
// program, to prove every tracked source file gets a `tsc` pass.
|
|
// • `verify:dep-lockstep` (#1965) asks which INSTALLED PACKAGES land in one, to
|
|
// find the packages that can appear twice — from two different installs — in
|
|
// a single program, which is the whole failure mode #1896 was about.
|
|
//
|
|
// Both answers come from the same `tsc --listFilesOnly` run: it reports every
|
|
// file the program resolves, including the ones reached only through another
|
|
// file's imports and the `.d.ts` files under `node_modules`. That is the accurate
|
|
// measure — a tsconfig's `include` names only the roots.
|
|
//
|
|
// The listing is memoized per (client, project) for the life of the process. It
|
|
// is deliberately NOT cached to disk across the two guard processes: a stale
|
|
// cache would make either guard measure a program that no longer exists and pass
|
|
// on a real miss — failing open, the one way these gates must never fail. The
|
|
// second pass costs ~14s (measured, #1965); that is cheaper than the class of
|
|
// bug a fingerprint that misses one input would introduce.
|
|
|
|
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";
|
|
|
|
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 client's root `tsconfig.json`. Non-empty for a `tsc -b`
|
|
* client (like `clients/web`, which has no `typecheck` script) — the guards
|
|
* enroll such a client through these instead of exempting the whole tree.
|
|
*/
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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. The two consumers want different slices of the files, so the slicing
|
|
* happens in {@link projectSourceFiles} / {@link projectPackageFiles} rather than
|
|
* here; they differ on the `error` too, which is why it is reported rather than
|
|
* only warned about. 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(
|
|
"npx",
|
|
["--no-install", "tsc", "-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 (`core/node_modules_fixtures/`) is not mistaken
|
|
* for an install — the same segment rule {@link classifyModulePath} applies.
|
|
*/
|
|
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; the aliased `core/` +
|
|
* `test-servers/` sources stay in the set but are harmless — the set is only ever
|
|
* queried with in-repo paths.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Repo-relative POSIX paths of the INSTALLED files ONE project's program
|
|
* resolves — everything under some `node_modules` inside the repo. Out-of-repo
|
|
* paths are dropped: an absolute `lib.d.ts` under the toolchain isn't an install
|
|
* this repo can align.
|
|
*/
|
|
export function projectPackageFiles(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;
|
|
}
|
|
|
|
/**
|
|
* The install a repo-relative `node_modules` path belongs to, and the package it
|
|
* holds — `{ installRoot, name }`, or null for a path that names neither.
|
|
*
|
|
* The install root is the prefix before the **first** `node_modules` segment
|
|
* (`.` for the repo root, `clients/web` for a client install), while the package
|
|
* name comes from after the **last** one. That asymmetry is deliberate: a
|
|
* *nested* `node_modules/a/node_modules/b` is npm resolving a transitive
|
|
* conflict **inside one install**, which is routine and not a cross-install
|
|
* skew — the same reason the lockfile comparison reads only top-level entries.
|
|
* Folding it onto its outermost install is what keeps the candidate set the
|
|
* handful of genuinely install-crossing packages instead of every transitive
|
|
* duplicate in the tree.
|
|
*/
|
|
export function classifyModulePath(rel) {
|
|
const parts = rel.split("/");
|
|
const first = parts.indexOf("node_modules");
|
|
if (first === -1) return null;
|
|
const last = parts.lastIndexOf("node_modules");
|
|
const head = parts[last + 1];
|
|
// `node_modules/.bin/…`, `node_modules/.package-lock.json`: npm's own
|
|
// bookkeeping, not a package.
|
|
if (!head || head.startsWith(".")) return null;
|
|
const name = head.startsWith("@")
|
|
? parts[last + 2] && `${head}/${parts[last + 2]}`
|
|
: head;
|
|
if (!name) return null; // a bare `node_modules/@scope` path names no package
|
|
return {
|
|
installRoot: first === 0 ? "." : parts.slice(0, first).join("/"),
|
|
name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The packages that enter a SINGLE program from two different installs — the
|
|
* only ones whose version skew can put two structurally-distinct copies of one
|
|
* type in front of the type checker.
|
|
*
|
|
* `programs` is an iterable of `{ label, files }`, one per leaf tsconfig project,
|
|
* `files` being repo-relative `node_modules` paths. Returns a Map keyed by
|
|
* package name, each value `{ installRoots, programs }` naming where it was seen
|
|
* — the diagnostic a failure needs.
|
|
*
|
|
* The two-install test is applied **within one program**, not across the run: a
|
|
* package reached from the root install in web's program and from the client
|
|
* install in cli's program is two copies in two *separate* type checks, which
|
|
* nothing has to relate.
|
|
*
|
|
* Note what TypeScript's own package-identity dedup does to this, because it
|
|
* makes the measure self-correcting rather than leaky. Two copies of a package
|
|
* at the **same** name@version are collapsed into a redirect, and a redirected
|
|
* `.d.ts` does not resolve its own imports — so an aligned package's transitive
|
|
* dependencies are loaded once, from one install, and never become candidates.
|
|
* That is the right answer, not a miss: one copy in the program is one copy for
|
|
* the checker to instantiate, whatever the tree looks like on disk. The moment
|
|
* the pair skews, the redirect stops applying, both copies enter the program,
|
|
* and the package becomes a candidate — which is exactly when it matters.
|
|
*/
|
|
export function crossInstallPackages(programs) {
|
|
const found = new Map();
|
|
for (const { label, files } of programs) {
|
|
const roots = new Map(); // name -> Set(installRoot), within THIS program
|
|
for (const rel of files) {
|
|
const hit = classifyModulePath(rel);
|
|
if (!hit) continue;
|
|
if (!roots.has(hit.name)) roots.set(hit.name, new Set());
|
|
roots.get(hit.name).add(hit.installRoot);
|
|
}
|
|
for (const [name, installRoots] of roots) {
|
|
if (installRoots.size < 2) continue;
|
|
if (!found.has(name))
|
|
found.set(name, { installRoots: new Set(), programs: new Set() });
|
|
const entry = found.get(name);
|
|
for (const root of installRoots) entry.installRoots.add(root);
|
|
entry.programs.add(label);
|
|
}
|
|
}
|
|
return found;
|
|
}
|