From Copilot's fifth pass of #1962. `TRIVIA` covered block comments but not `//`, despite the contract right above it saying a comment is legal wherever whitespace is — so `import(// lazy\n"pkg")`, `require(// lazy\n"pkg")`, and `from // reason\n"pkg"` were stepped over, and such a package would never enter the candidate set. Same silent-miss direction as the block-comment gap. Both comment syntaxes are now covered. The line-comment branch runs to end-of-line only; the newline itself is matched by TRIVIA's `\s` branch. Regression test added alongside the block-comment one. The derived set is unchanged at 18 real names — widening trivia does not admit prose, since a specifier must still follow immediately and `packageNameOf` rejects anything not shaped like one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
388 lines
17 KiB
JavaScript
388 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
// Durable guard for the "one version per install-crossing dependency" invariant
|
|
// (#1896). v2 is not an npm workspace: the root and each `clients/*` carry their
|
|
// own `node_modules`, so the *same* package can resolve to two different
|
|
// versions in one process — or, worse, in one `tsc` program.
|
|
//
|
|
// That second case is what this guard exists for. A client's
|
|
// `tsconfig.test.json` compiles first-party sources that live *outside* the
|
|
// client (`test-servers/src`, `core/`), and those files resolve their
|
|
// dependencies from the **root** install while the client's own sources resolve
|
|
// from the client install. When the two copies are the same version the
|
|
// duplication is harmless; when they skew, TypeScript must relate two
|
|
// structurally-distinct declarations of the same type.
|
|
//
|
|
// For most packages that is merely redundant work. For a deeply
|
|
// recursive-generic type surface it is exponential: zod `4.3.6` (root) against
|
|
// zod `4.4.3` (clients/web) made `tsc -b` in `clients/web` exhaust the 4GB
|
|
// default heap outright (`FATAL ERROR: Ineffective mark-compacts near heap
|
|
// limit`) via `TS2589 Type instantiation is excessively deep`, because every
|
|
// `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the
|
|
// two copies — changing nothing else — returned the build to its baseline cost.
|
|
//
|
|
// The candidate set is DERIVED, not hand-listed: it is the packages imported by
|
|
// the shared first-party TypeScript — `core/`, `test-servers/src`, and the
|
|
// root-owned `vitest.shared.mts` — the surfaces compiled into more than one
|
|
// client's program. Skew is then denied by default, with a small
|
|
// allowlist of packages verified to tolerate it (below). A dependency that
|
|
// starts skewing therefore fails `validate` and forces a decision, rather than
|
|
// surfacing months later as an unexplained OOM.
|
|
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { builtinModules } from "node:module";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { rootReachesScript } from "./lib/npm-scripts.mjs";
|
|
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
);
|
|
|
|
// The first-party source trees that are compiled into more than one install's
|
|
// `tsc` program, and so define which dependencies can appear twice in one
|
|
// program. `core/` is consumed by every client via the `@inspector/core` alias;
|
|
// `test-servers/src` is pulled into the web and cli test projects.
|
|
const SHARED_SOURCE_DIRS = ["core", "test-servers/src"];
|
|
|
|
// Individual root-owned TypeScript files that are shared the same way but sit
|
|
// outside those trees. `vitest.shared.mts` is imported by every client's vitest
|
|
// config, and `verify:typecheck-coverage` already treats it as shared
|
|
// non-client source. It imports only Node built-ins today — which is precisely
|
|
// why omitting it would go unnoticed until a third-party import appeared there,
|
|
// resolved from the root, and skewed (Copilot, #1962).
|
|
const SHARED_SOURCE_FILES = ["vitest.shared.mts"];
|
|
|
|
// Packages whose cross-install skew is verified benign, each with the reason.
|
|
// This is an allowlist of *names*, not of version pairs, so an ordinary patch
|
|
// float within one of these does not churn the file — while any package NOT
|
|
// listed here failing the check is a genuine, unreviewed new skew.
|
|
//
|
|
// Being listed is NOT a blanket exemption: it tolerates skew only *within a
|
|
// major version*. Each rationale below establishes that a patch/minor
|
|
// difference is harmless, which is not evidence that a React 18-vs-19 or Hono
|
|
// 4-vs-5 split across installs would be — that is a different type surface, and
|
|
// it fails like anything else (Copilot, #1962).
|
|
//
|
|
// The admission test is the one the zod incident established: does the
|
|
// package's public type surface consist of deeply recursive generics that
|
|
// first-party code relates across the boundary? If yes it must stay in
|
|
// lockstep; if no, a patch-level difference costs nothing.
|
|
const TOLERATED_SKEW = new Map([
|
|
[
|
|
"react",
|
|
"Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.",
|
|
],
|
|
[
|
|
"hono",
|
|
"Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.",
|
|
],
|
|
[
|
|
"jose",
|
|
"Consumed as flat function calls in `core/auth`; no generic type flows between installs.",
|
|
],
|
|
[
|
|
"@modelcontextprotocol/ext-apps",
|
|
"Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.",
|
|
],
|
|
]);
|
|
|
|
/** Package names that are Node built-ins (with or without the `node:` prefix). */
|
|
const BUILTINS = new Set([
|
|
...builtinModules,
|
|
...builtinModules.map((m) => `node:${m}`),
|
|
]);
|
|
|
|
// A bare package specifier: optional `@scope/`, then a name, then any subpath.
|
|
// Anchored so prose that happens to sit after the word `from` in a comment
|
|
// ("from cwd omitted") cannot be mistaken for an import.
|
|
const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/;
|
|
|
|
/**
|
|
* The bare package name a module specifier resolves to — `@scope/name` or
|
|
* `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for
|
|
* relative paths, built-ins, URLs, and anything not shaped like a specifier.
|
|
*/
|
|
export function packageNameOf(specifier) {
|
|
if (typeof specifier !== "string" || specifier === "") return null;
|
|
if (specifier.startsWith(".") || specifier.startsWith("/")) return null;
|
|
if (BUILTINS.has(specifier)) return null;
|
|
const m = PACKAGE_SPECIFIER.exec(specifier);
|
|
if (!m) return null;
|
|
const name = m[1] ? `${m[1]}/${m[2]}` : m[2];
|
|
// A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package.
|
|
if (name.includes(":")) return null;
|
|
return name;
|
|
}
|
|
|
|
// Every form that can introduce a dependency's *types* (Copilot, #1962).
|
|
// `import x = require("pkg")` and a bare `require("pkg")` matter because the
|
|
// shared trees may hold `.cts` sources, where that is the ordinary import
|
|
// syntax; and neither call form requires the closing paren, so an import
|
|
// attributes argument (`import("pkg", { with: … })`) can't hide the specifier.
|
|
//
|
|
// Backticks are accepted ONLY in the call forms, where a static template
|
|
// literal is legal. A `from` clause requires a string literal, so allowing
|
|
// backticks there would buy nothing while reopening the prose hazard the
|
|
// anchored specifier pattern exists to close — inline code in a comment is
|
|
// written with backticks throughout this codebase, and "…derived from
|
|
// `tools`…" would otherwise enter the candidate set as a package named
|
|
// `tools`. Inert today, but a prose word that collides with a real package
|
|
// name would silently widen the set.
|
|
//
|
|
// Whitespace between tokens is really *trivia*: TypeScript allows a comment
|
|
// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`,
|
|
// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`)
|
|
// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for
|
|
// `\s*` at every such position and covers both comment syntaxes. A line comment
|
|
// runs to end-of-line only — the newline itself is matched by the `\s` branch.
|
|
const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`;
|
|
const SPECIFIER_FORMS = [
|
|
// import … from "x" / export … from "x"
|
|
new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"),
|
|
// side-effect import "x"
|
|
new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"),
|
|
// dynamic import("x"[, opts])
|
|
new RegExp(
|
|
String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`,
|
|
"g",
|
|
),
|
|
// import x = require("x"), require("x")
|
|
new RegExp(
|
|
String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`,
|
|
"g",
|
|
),
|
|
];
|
|
|
|
/**
|
|
* Every third-party package name imported by a blob of TypeScript source.
|
|
* Deliberately a regex scan rather than a parse: it only needs to over- rather
|
|
* than under-approximate, since a name absent from every lockfile contributes
|
|
* nothing downstream (`@inspector/core` is a build-time alias, not a package,
|
|
* and drops out that way).
|
|
*/
|
|
export function importedPackageNames(source) {
|
|
const names = new Set();
|
|
for (const re of SPECIFIER_FORMS) {
|
|
for (const m of source.matchAll(re)) {
|
|
const name = packageNameOf(m[1]);
|
|
if (name) names.add(name);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Top-level installed versions of every package in a parsed lockfile, keyed by
|
|
* package name. Only `node_modules/<pkg>` entries count — a *nested*
|
|
* `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside
|
|
* one install, which is routine and not what this guard is about.
|
|
*/
|
|
export function topLevelLockVersions(lock) {
|
|
const versions = new Map();
|
|
for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) {
|
|
const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath);
|
|
if (!m || typeof entry?.version !== "string") continue;
|
|
versions.set(m[1], entry.version);
|
|
}
|
|
return versions;
|
|
}
|
|
|
|
/**
|
|
* Find candidate packages that resolve to more than one version across the
|
|
* installs. `installs` is an array of `{ dir, versions }`. Returns one entry per
|
|
* skewed package, sorted by name, each listing the version each install holds.
|
|
* Packages present in fewer than two installs cannot skew and are skipped.
|
|
*/
|
|
export function findSkew(candidates, installs) {
|
|
const skewed = [];
|
|
for (const name of [...candidates].sort()) {
|
|
const holders = installs
|
|
.filter(({ versions }) => versions.has(name))
|
|
.map(({ dir, versions }) => ({ dir, version: versions.get(name) }));
|
|
if (holders.length < 2) continue;
|
|
const distinct = new Set(holders.map((h) => h.version));
|
|
if (distinct.size > 1) skewed.push({ name, holders });
|
|
}
|
|
return skewed;
|
|
}
|
|
|
|
/**
|
|
* The major-version component of a lockfile version string. Prerelease and
|
|
* build metadata are irrelevant here (`2.0.0-beta.5` → `2`). Returns null for
|
|
* anything not starting with an integer, which is treated as "cannot prove same
|
|
* major" and therefore fails rather than passes.
|
|
*/
|
|
export function majorOf(version) {
|
|
const m = /^(\d+)\./.exec(String(version ?? ""));
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Split skewed packages into the tolerated ones and the failures.
|
|
*
|
|
* Being on the allowlist tolerates skew only *within a major version*: each
|
|
* entry's rationale establishes that a patch/minor difference is benign, which
|
|
* says nothing about a major split, where the type surface itself changes. So a
|
|
* listed package whose holders disagree on major is still a failure.
|
|
*/
|
|
export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) {
|
|
const isTolerated = (s) => {
|
|
if (!tolerated.has(s.name)) return false;
|
|
const majors = new Set(s.holders.map((h) => majorOf(h.version)));
|
|
return majors.size === 1 && !majors.has(null);
|
|
};
|
|
return {
|
|
failures: skewed.filter((s) => !isTolerated(s)),
|
|
ignored: skewed.filter(isTolerated),
|
|
};
|
|
}
|
|
|
|
// The TypeScript extensions the shared trees can hold. Deliberately the same
|
|
// four `verify:format-coverage` and `verify:typecheck-coverage` gate on: a
|
|
// `.mts`/`.cts` under `core/` or `test-servers/src` is typechecked like any
|
|
// other source, so its imports must reach the candidate set too. None exist
|
|
// under those trees today, which is exactly why omitting them would go
|
|
// unnoticed until a new shared dependency arrived through one and skewed.
|
|
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
|
|
|
|
/**
|
|
* Whether a repo-relative path is shared first-party TypeScript — under one of
|
|
* the shared trees, or one of the individually-named shared files.
|
|
*/
|
|
export function isSharedSourceFile(file) {
|
|
if (SHARED_SOURCE_FILES.includes(file)) return true;
|
|
if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false;
|
|
// Anchored on a path boundary so a sibling whose name merely starts with a
|
|
// shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in.
|
|
return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`));
|
|
}
|
|
|
|
/** Tracked TypeScript files under the shared first-party source trees. */
|
|
function sharedSourceFiles() {
|
|
const out = execFileSync(
|
|
"git",
|
|
[
|
|
"ls-files",
|
|
"--",
|
|
...SHARED_SOURCE_DIRS.map((d) => `${d}/**`),
|
|
...SHARED_SOURCE_FILES,
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8" },
|
|
);
|
|
return out.split("\n").filter(isSharedSourceFile);
|
|
}
|
|
|
|
/**
|
|
* The installs to compare: the repo root plus every `clients/*` that carries a
|
|
* lockfile. Discovered from disk rather than listed, so a new client is covered
|
|
* without editing this guard (the same enrollment style as
|
|
* `verify:typecheck-coverage`).
|
|
*/
|
|
function installDirs() {
|
|
const clientsDir = path.join(repoRoot, "clients");
|
|
const clients = existsSync(clientsDir)
|
|
? readdirSync(clientsDir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => `clients/${e.name}`)
|
|
.sort()
|
|
: [];
|
|
return ["."]
|
|
.concat(clients)
|
|
.filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json")));
|
|
}
|
|
|
|
/**
|
|
* Run the guard. 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 rootScripts = JSON.parse(
|
|
readFileSync(path.join(repoRoot, "package.json"), "utf8"),
|
|
).scripts;
|
|
|
|
// Vouch for a sibling guard: a guard cannot detect being unrun itself, but the
|
|
// three can vouch for one another, so dropping any single one from `validate`
|
|
// is caught by another. `verify:format-coverage` vouches for this one in turn.
|
|
if (!rootReachesScript(rootScripts, "verify:format-coverage")) {
|
|
console.error(
|
|
"verify:dep-lockstep — the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const files = sharedSourceFiles();
|
|
if (files.length === 0) {
|
|
console.error(
|
|
`verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const candidates = new Set();
|
|
for (const file of files) {
|
|
const source = readFileSync(path.join(repoRoot, file), "utf8");
|
|
for (const name of importedPackageNames(source)) candidates.add(name);
|
|
}
|
|
|
|
const dirs = installDirs();
|
|
const installs = dirs.map((dir) => ({
|
|
dir,
|
|
versions: topLevelLockVersions(
|
|
JSON.parse(
|
|
readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"),
|
|
),
|
|
),
|
|
}));
|
|
|
|
const { failures, ignored } = partitionSkew(findSkew(candidates, installs));
|
|
|
|
if (failures.length > 0) {
|
|
console.error(
|
|
`verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`,
|
|
);
|
|
let anyListed = false;
|
|
for (const { name, holders } of failures) {
|
|
// A package already on the allowlist reached here only by skewing across
|
|
// a MAJOR boundary, so say that rather than advising an entry that exists.
|
|
const listed = TOLERATED_SKEW.has(name);
|
|
anyListed ||= listed;
|
|
console.error(
|
|
` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`,
|
|
);
|
|
for (const { dir, version } of holders)
|
|
console.error(` ${version} (${dir})`);
|
|
}
|
|
const shared = [...SHARED_SOURCE_DIRS, ...SHARED_SOURCE_FILES].join(", ");
|
|
console.error(
|
|
"\nThese packages' types are compiled into a single `tsc` program from two installs" +
|
|
`\n(${shared} resolve from the root, a client's own sources from the client),` +
|
|
"\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" +
|
|
"\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.",
|
|
);
|
|
console.error(
|
|
"\nAlign them — `npm install <pkg>@<version>` in each install that declares the package, so all" +
|
|
"\nlockfiles agree. (Don't add it to an install that doesn't declare it: a package absent from an" +
|
|
"\ninstall can't skew.) If instead its types genuinely cannot blow up, add it to TOLERATED_SKEW in" +
|
|
"\nscripts/verify-dep-lockstep.mjs with the reason. See AGENTS.md.",
|
|
);
|
|
if (anyListed)
|
|
console.error(
|
|
"\nNote: an allowlisted package is tolerated only WITHIN a major version — the rationale for one" +
|
|
"\nestablishes that a patch/minor difference is benign, not that a major split is. Align the major.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : "";
|
|
console.log(
|
|
`verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`,
|
|
);
|
|
}
|
|
|
|
// Run only when executed directly (`node scripts/verify-dep-lockstep.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();
|