Merge pull request #2029 from modelcontextprotocol/v2/chore/1965-dep-lockstep-tsc-derivation

chore(scripts): derive dep-lockstep candidates from what enters each tsc program
This commit is contained in:
Cliff Hall 2026-08-16 17:37:12 -04:00 • committed by GitHub
commit 6bc355df63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1501 additions and 1058 deletions

View file

@ -100,7 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
## Gates and PR hygiene
- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating.
- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency.
- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency.
- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged.
- **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`.
- **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch.

File diff suppressed because one or more lines are too long

View file

@ -294,14 +294,14 @@ Each client self-validates from its own folder; the root scripts chain them. The
| Script | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. |
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`.
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |

396
scripts/lib/tsc-program.mjs Normal file
View file

@ -0,0 +1,396 @@
// 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;
}
/**
* Where a repo-relative `node_modules` path came from — `{ installRoot, name,
* entryPath }`, or null for a path that names no package.
*
* - `installRoot` is the prefix before the **first** `node_modules` segment
* (`.` for the repo root, `clients/web` for a client install). A *nested*
* `node_modules/a/node_modules/b` folds onto its outermost install, because it
* is npm resolving a transitive conflict **inside one install** — routine, and
* not the cross-install skew this feeds. Folding it is what keeps the
* candidate set the handful of genuinely install-crossing packages instead of
* every transitive duplicate in the tree.
* - `name` comes from after the **last** `node_modules`, so the nested copy is
* still attributed to the package it actually is.
* - `entryPath` is the package directory relative to that install — exactly the
* key npm writes in that install's lockfile (`node_modules/zod`,
* `node_modules/a/node_modules/zod`). Folding tells you *which install* loaded
* it; this tells you *which copy*, so the version can be read from the entry
* the program actually resolved rather than from whatever sits at the
* install's top level (Copilot, #1965 r1 — a nested copy at a different
* version would otherwise be compared against a top-level entry that is
* absent, or worse, present and aligned, and the real pair would pass).
*/
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 scoped = head.startsWith("@");
const name = scoped ? 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,
entryPath: parts.slice(first, last + (scoped ? 3 : 2)).join("/"),
};
}
/**
* 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
* `Map<name, Map<programLabel, Map<installRoot, Set<entryPath>>>>` — the
* co-occurrences themselves, not a flattened name list.
*
* Keeping the structure is what lets the caller compare only the copies that met
* (Copilot, #1965 r1). Flattening to names hands the comparison a package that
* is aligned wherever it co-occurs, and a third install holding a different
* version of it then fails the guard even though no program ever loads that
* copy — with a diagnostic naming an install that was never involved.
*
* 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) {
// Within THIS program: name -> installRoot -> the entry paths loaded from it.
const seen = new Map();
for (const rel of files) {
const hit = classifyModulePath(rel);
if (!hit) continue;
if (!seen.has(hit.name)) seen.set(hit.name, new Map());
const byRoot = seen.get(hit.name);
if (!byRoot.has(hit.installRoot)) byRoot.set(hit.installRoot, new Set());
byRoot.get(hit.installRoot).add(hit.entryPath);
}
for (const [name, byRoot] of seen) {
if (byRoot.size < 2) continue; // one install — nothing to relate
if (!found.has(name)) found.set(name, new Map());
found.get(name).set(label, byRoot);
}
}
return found;
}

View file

@ -0,0 +1,292 @@
// Table-driven tests for the shared `tsc` program helpers — the machinery both
// `verify:typecheck-coverage` (#1791) and `verify:dep-lockstep` (#1965) read a
// program through. One case per rule; the comment names the rule, so relaxing
// one shows up as a deleted assertion rather than a quiet behavior shift. The
// script-parsing and tsconfig-graph cases moved here with their code when the
// two guards were unified; their `(rN)` tags are the #1799 review rounds that
// found them. Run via `npm run test:scripts`.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
classifyModulePath,
crossInstallPackages,
isDisablingFlag,
isTsc,
parseTsconfigReferences,
projectConfigFile,
refToProject,
typecheckProjects,
} from "./tsc-program.mjs";
test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => {
for (const t of [
"tsc",
"node_modules/.bin/tsc",
"./node_modules/.bin/tsc.cmd",
])
assert.ok(isTsc(t), t);
for (const t of ["vitest", "prettier", "tscx", "atsc"])
assert.ok(!isTsc(t), t);
});
test("isDisablingFlag: case-insensitive (r18)", () => {
for (const t of [
"--noCheck",
"--nocheck",
"--listFilesOnly",
"--LISTFILESONLY",
])
assert.ok(isDisablingFlag(t), t);
for (const t of ["--noEmit", "-p", "--project", "noCheck"])
assert.ok(!isDisablingFlag(t), t);
});
test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => {
const { projects, neutered } = typecheckProjects({
typecheck:
"tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
});
assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]);
assert.equal(neutered.length, 0);
// A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json.
assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [
"tsconfig.json",
]);
// Path-invoked binary still counts (r18).
assert.deepEqual(
typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" })
.projects,
["tsconfig.json"],
);
// A quoted project path (r17).
assert.deepEqual(
typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects,
["tsconfig.test.json"],
);
// `--project` long form, and `-b`/`--build` project paths (r13).
const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects;
assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [
"tsconfig.json",
]);
assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]);
assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]);
assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback
});
test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => {
const { projects, neutered } = typecheckProjects({
typecheck:
"tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json",
});
assert.deepEqual(projects, ["tsconfig.test.json"]);
assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]);
});
test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => {
const { projects } = typecheckProjects({
typecheck: "npm run typecheck:src && npm run typecheck:test",
"typecheck:src": "tsc --noEmit -p tsconfig.json",
"typecheck:test": "tsc --noEmit --project tsconfig.test.json",
});
assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]);
});
test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => {
const refs = (raw) => parseTsconfigReferences(raw);
assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]);
assert.deepEqual(
refs('/* solution */\n{ "references": [{ "path": "./a" }] }'),
["./a"],
);
assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [
"./a",
]);
assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma
assert.deepEqual(refs('{ "files": [] }'), []); // no references
assert.deepEqual(refs("{ not json"), []); // malformed
assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path
});
test("projectConfigFile: directory-form entry means <dir>/tsconfig.json (r26)", () => {
assert.equal(
projectConfigFile("clients/cli", "tsconfig.test.json"),
"clients/cli/tsconfig.test.json",
);
assert.equal(
projectConfigFile("clients/cli", "packages/a"),
"clients/cli/packages/a/tsconfig.json",
);
assert.equal(
projectConfigFile("clients/cli", "."),
"clients/cli/tsconfig.json",
);
});
test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => {
// A ref is relative to the tsconfig that declares it, not to clientDir.
assert.equal(
refToProject(
"clients/web",
"clients/web/tsconfig.json",
"./tsconfig.app.json",
),
"tsconfig.app.json",
);
assert.equal(
refToProject(
"clients/web",
"clients/web/sub/tsconfig.json",
"../other.json",
),
"other.json",
);
assert.equal(
refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"),
"sub/deep",
);
});
test("classifyModulePath: install root is the OUTERMOST node_modules, package the innermost (#1965)", () => {
assert.deepEqual(classifyModulePath("node_modules/zod/index.d.ts"), {
installRoot: ".",
name: "zod",
entryPath: "node_modules/zod",
});
assert.deepEqual(
classifyModulePath("clients/web/node_modules/zod/index.d.ts"),
{
installRoot: "clients/web",
name: "zod",
entryPath: "node_modules/zod",
},
);
// A scoped package keeps both segments.
assert.deepEqual(
classifyModulePath("node_modules/@modelcontextprotocol/sdk/dist/x.d.ts"),
{
installRoot: ".",
name: "@modelcontextprotocol/sdk",
entryPath: "node_modules/@modelcontextprotocol/sdk",
},
);
// A NESTED copy folds onto its outermost install: npm resolving a transitive
// conflict inside one install is routine, not a cross-install skew. Its
// `entryPath` still names the copy itself, so the caller can price the version
// the program actually loaded (Copilot, #1965 r1).
assert.deepEqual(
classifyModulePath("node_modules/a/node_modules/zod/index.d.ts"),
{
installRoot: ".",
name: "zod",
entryPath: "node_modules/a/node_modules/zod",
},
);
assert.deepEqual(
classifyModulePath(
"clients/cli/node_modules/a/node_modules/@scope/b/index.d.ts",
),
{
installRoot: "clients/cli",
name: "@scope/b",
entryPath: "node_modules/a/node_modules/@scope/b",
},
);
});
test("classifyModulePath: paths that name no package", () => {
for (const rel of [
"core/mcp/client.ts", // first-party source
"clients/web/src/App.tsx",
"node_modules/.bin/tsc", // npm bookkeeping, not a package
"node_modules/.package-lock.json",
"node_modules/@scope", // a scope directory names no package
"node_modules", // the directory itself
])
assert.equal(classifyModulePath(rel), null, rel);
});
test("classifyModulePath: a path segment merely CONTAINING node_modules is not one", () => {
// Segment-wise matching, not substring: a first-party dir whose name embeds
// the word would otherwise be read as an install root.
assert.equal(classifyModulePath("core/node_modules_fixtures/a.ts"), null);
});
test("crossInstallPackages: two installs in ONE program is the whole test (#1965)", () => {
const found = crossInstallPackages([
{
label: "clients/web/tsconfig.test.json",
files: [
"node_modules/zod/index.d.ts",
"clients/web/node_modules/zod/index.d.ts",
"clients/web/node_modules/react/index.d.ts",
],
},
]);
assert.deepEqual([...found.keys()], ["zod"]);
const byRoot = found.get("zod").get("clients/web/tsconfig.test.json");
assert.deepEqual([...byRoot.keys()].sort(), [".", "clients/web"]);
// The entry paths ride along so the caller can price each copy from its own
// lockfile entry (Copilot, #1965 r1).
assert.deepEqual([...byRoot.get(".")], ["node_modules/zod"]);
});
test("crossInstallPackages: two installs across SEPARATE programs is not a candidate", () => {
// Two copies the type checker never has to relate — each program sees one.
const found = crossInstallPackages([
{ label: "a", files: ["node_modules/zod/index.d.ts"] },
{ label: "b", files: ["clients/cli/node_modules/zod/index.d.ts"] },
]);
assert.equal(found.size, 0);
});
test("crossInstallPackages: a nested duplicate inside one install is not a candidate", () => {
const found = crossInstallPackages([
{
label: "a",
files: [
"node_modules/zod/index.d.ts",
"node_modules/other/node_modules/zod/index.d.ts",
],
},
]);
assert.equal(found.size, 0);
});
test("crossInstallPackages: every program that saw both copies is recorded", () => {
const both = [
"node_modules/zod/index.d.ts",
"clients/web/node_modules/zod/index.d.ts",
];
const found = crossInstallPackages([
{ label: "web/app", files: both },
{ label: "web/test", files: both },
{ label: "cli/src", files: ["node_modules/zod/index.d.ts"] },
]);
assert.deepEqual([...found.get("zod").keys()].sort(), [
"web/app",
"web/test",
]);
});
test("crossInstallPackages: a nested copy is kept as its own entry path (Copilot, #1965 r1)", () => {
// Folded onto the root install for candidacy, but recorded at the path it was
// loaded from so its real version can be read.
const found = crossInstallPackages([
{
label: "p",
files: [
"node_modules/a/node_modules/zod/index.d.ts",
"clients/web/node_modules/zod/index.d.ts",
],
},
]);
assert.deepEqual(
[...found.get("zod").get("p").get(".")],
["node_modules/a/node_modules/zod"],
);
});

View file

@ -1,14 +1,21 @@
// End-to-end tests for the dep-lockstep guard's executable path (Copilot,
// #1962). The sibling tests cover the pure helpers; nothing exercised `main()`,
// so a regression in source enumeration, install discovery, lockfile loading,
// so a regression in candidate derivation, install discovery, lockfile loading,
// the sibling-guard vouch, or the nonzero exit on real skew would have left the
// whole suite green.
//
// Each case builds a throwaway repo — the guard derives its root from its own
// file location, so the script is copied into the fixture rather than pointed
// at one — `git add`s it (the enumeration is `git ls-files`, which reads the
// index), and runs the guard as a subprocess to assert the exit status and
// message. Run via `npm run test:scripts`.
// at one — `git add`s it (install discovery is by manifest, but the sibling
// `verify:format-coverage` vouch and git-based tooling expect a repo), and runs
// the guard as a subprocess to assert the exit status and message.
//
// Since #1965 the fixture must contain a REAL tsc program: the candidate set is
// derived from what `tsc --listFilesOnly` resolves, so each fixture ships two
// installs holding the same tiny stub packages, one client tsconfig whose
// program spans both (its own `src` resolves from the client install, the shared
// `core/` it includes resolves from the root — the actual v2 shape), and a
// symlinked real `typescript` to run it. Run via `npm run test:scripts`.
import { test } from "node:test";
import assert from "node:assert/strict";
@ -30,21 +37,25 @@ import { fileURLToPath } from "node:url";
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(scriptsDir, "..");
/** The real typescript install, symlinked into each fixture so the guard resolves it. */
/** The real typescript install, symlinked into each fixture so `npx tsc` runs. */
const typescriptDir = path.dirname(
createRequire(path.join(repoRoot, "clients", "web", "package.json")).resolve(
"typescript/package.json",
),
);
/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */
/**
* A lockfileVersion 3 lockfile, including the `""` root entry npm always writes.
* A key already containing `node_modules/` is used verbatim, so a nested install
* path (`node_modules/outer/node_modules/inner`) can be expressed.
*/
const lock = (deps) => ({
lockfileVersion: 3,
packages: {
"": { name: "fixture" },
...Object.fromEntries(
Object.entries(deps).map(([name, version]) => [
`node_modules/${name}`,
name.includes("node_modules/") ? name : `node_modules/${name}`,
{ version },
]),
),
@ -52,11 +63,26 @@ const lock = (deps) => ({
});
/**
* Build a fixture repo: shared sources importing `zod` and `express`, a root
* install and one client install, and the guard itself. `rootDeps`/`webDeps`
* decide whether the two installs agree.
* Build a fixture repo whose one client program spans two installs.
*
* `outer` is named by first-party code in both trees; `inner` is named ONLY by
* `outer`'s own `.d.ts`, so it reaches the program transitively — the
* `@modelcontextprotocol/sdk` shape #1965 is about. Both stubs are installed
* under the root AND under `clients/web`, so each program holds two copies of
* each and both are candidates. `solo` is installed at the root only and
* imported only by the shared tree, so it can never be a candidate however its
* lockfile entries read.
*/
function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) {
function makeFixture({
rootDeps,
webDeps,
scripts,
webScripts,
rawWebLock,
webTsconfig,
rootNestedInner,
cliDeps,
} = {}) {
// realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to
// `/private/var/...`. The guard only runs `main()` when `import.meta.url`
// (always the resolved path) matches `process.argv[1]`, so launching it via
@ -81,34 +107,136 @@ function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) {
"verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs",
},
});
write("core/client.ts", 'import { z } from "zod";\nexport const s = z;\n');
// The shared tree, compiled into the client's program but resolving its own
// imports from the ROOT install — the reason a package can appear twice.
write(
"test-servers/src/server.ts",
'import express from "express";\nexport const app = express;\n',
"core/client.ts",
'import type { Outer } from "outer";\nimport type { Solo } from "solo";\nexport type FromCore = Outer | Solo;\n',
);
// The client's own source, resolving from the CLIENT install.
write(
"vitest.shared.mts",
'import path from "node:path";\nexport default path;\n',
"clients/web/src/main.ts",
'import type { Outer } from "outer";\nexport type FromWeb = Outer;\n',
);
write("package-lock.json", lock(rootDeps));
write("clients/web/package.json", { name: "web" });
write("clients/web/package-lock.json", rawWebLock ?? lock(webDeps));
write("clients/web/package.json", {
name: "web",
scripts: webScripts ?? { typecheck: "tsc --noEmit -p tsconfig.json" },
});
write(
"clients/web/tsconfig.json",
webTsconfig ?? {
compilerOptions: {
noEmit: true,
module: "esnext",
target: "esnext",
moduleResolution: "bundler",
types: [],
},
include: ["src/**/*.ts", "../../core/**/*.ts"],
},
);
// Stub installs. `outer` pulls `inner` in through its own declarations. The
// installed version must match the lockfile's: TypeScript keys its
// package-identity dedup on name@version, so a stub that lied about its
// version would make the program under test differ from the one the lockfile
// describes.
const root = rootDeps ?? ALIGNED;
const web = webDeps ?? ALIGNED;
const stub = (installRoot, versions, name, body) => {
if (!versions[name]) return;
write(`${installRoot}node_modules/${name}/package.json`, {
name,
version: versions[name],
types: "index.d.ts",
});
write(`${installRoot}node_modules/${name}/index.d.ts`, body);
};
for (const [installRoot, versions] of [
["", root],
["clients/web/", web],
]) {
stub(
installRoot,
versions,
"outer",
'import type { Inner } from "inner";\nexport type Outer = Inner;\n',
);
stub(installRoot, versions, "inner", "export type Inner = number;\n");
}
stub("", root, "solo", "export type Solo = string;\n");
// The root reaches `inner` only through a copy NESTED under `outer`, at a
// version its top-level entry does not carry. npm's own conflict resolution,
// and the case that must still be priced from the entry the program resolved.
const rootLockDeps = { ...root };
if (rootNestedInner) {
stub(
"node_modules/outer/",
{ inner: rootNestedInner },
"inner",
"export type Inner = number;\n",
);
rootLockDeps["node_modules/outer/node_modules/inner"] = rootNestedInner;
}
write("package-lock.json", lock(rootLockDeps));
write("clients/web/package-lock.json", rawWebLock ?? lock(web));
// A second client, to prove a third install's copy is not dragged into a
// comparison it never took part in. Its program spans only its own install.
if (cliDeps) {
write("clients/cli/package.json", {
name: "cli",
scripts: { typecheck: "tsc --noEmit -p tsconfig.json" },
});
write("clients/cli/tsconfig.json", {
compilerOptions: {
noEmit: true,
module: "esnext",
target: "esnext",
moduleResolution: "bundler",
types: [],
},
include: ["src/**/*.ts"],
});
write(
"clients/cli/src/main.ts",
'import type { Outer } from "outer";\nexport type FromCli = Outer;\n',
);
stub(
"clients/cli/",
cliDeps,
"outer",
'import type { Inner } from "inner";\nexport type Outer = Inner;\n',
);
stub("clients/cli/", cliDeps, "inner", "export type Inner = number;\n");
write("clients/cli/package-lock.json", lock(cliDeps));
}
// The guard resolves its repo root from its own location, so it has to live
// inside the fixture; `lib/npm-scripts.mjs` comes along as its import.
// inside the fixture; its `lib/` imports come along.
mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true });
for (const rel of [
"verify-dep-lockstep.mjs",
path.join("lib", "npm-scripts.mjs"),
path.join("lib", "tsc-program.mjs"),
])
cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel));
mkdirSync(path.join(dir, "node_modules"), { recursive: true });
// A real typescript, plus the `.bin` entry `npx --no-install tsc` resolves by
// walking up from the client dir.
mkdirSync(path.join(dir, "node_modules", ".bin"), { recursive: true });
symlinkSync(
typescriptDir,
path.join(dir, "node_modules", "typescript"),
"dir",
);
symlinkSync(
path.join(typescriptDir, "bin", "tsc"),
path.join(dir, "node_modules", ".bin", "tsc"),
);
execFileSync("git", ["init", "-q"], { cwd: dir });
execFileSync("git", ["add", "-A"], { cwd: dir });
@ -134,69 +262,149 @@ function withFixture(options, fn) {
}
}
const ALIGNED = { zod: "4.4.3", express: "5.2.1" };
const ALIGNED = { outer: "1.2.3", inner: "4.5.6", solo: "7.8.9" };
test("main: exits 0 when every install agrees", () => {
withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => {
withFixture({}, (dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 0, out);
assert.match(out, /verify:dep-lockstep — OK/);
// Both shared trees and the named shared file contributed their imports.
assert.match(out, /2 install-crossing dependencies/);
// `outer` alone: both installs hold it and both files land in the program.
// `solo` is installed at the root only, so it is not install-crossing — and
// `inner`, at the same version in both installs, is collapsed by
// TypeScript's package-identity redirect (see the note on `crossInstallPackages`
// in lib/tsc-program.mjs): only one copy is ever loaded, so there is nothing
// for the checker to relate. The next test is the version that matters.
assert.match(out, /1 install-crossing dependencies/);
assert.match(out, /1 tsc programs/);
});
});
test("main: exits 1 and names the skewed package and every holder", () => {
withFixture(
{ rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED },
{ rootDeps: { ...ALIGNED, outer: "1.0.0" }, webDeps: ALIGNED },
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /\bzod\b/);
assert.match(out, /4\.3\.6\s+\(\.\)/);
assert.match(out, /4\.4\.3\s+\(clients\/web\)/);
// `express` agrees, so it must not be reported.
assert.doesNotMatch(out, /^\s+express$/m);
assert.match(out, /\bouter\b/);
assert.match(out, /1\.0\.0\s+\(\.\/node_modules\/outer\)/);
assert.match(out, /1\.2\.3\s+\(clients\/web\/node_modules\/outer\)/);
// The program that saw both copies is named, so the claim is checkable.
assert.match(out, /in clients\/web\/tsconfig\.json/);
// `inner` agrees, so it must not be reported.
assert.doesNotMatch(out, /^\s+inner$/m);
},
);
});
test("main: a package imported only by test-servers/src is still checked", () => {
// Guards the enumeration of the *second* shared tree: if only `core/` were
// scanned, this skew would pass.
test("main: a package reached only through another package's .d.ts is caught (#1965)", () => {
// `inner` is never written in first-party code — it enters the program solely
// through `outer`'s declarations. The derivation this replaced read the
// shared sources' own imports and could not see it, which is how
// `@modelcontextprotocol/sdk` sat skewed across two installs while the guard
// stayed green. Its parent skews too, which is what puts both copies of the
// parent in the program to resolve from — the real #1965 shape, where
// `ext-apps` was split 1.7.4/1.7.5 alongside the SDK.
withFixture(
{ rootDeps: { ...ALIGNED, express: "5.0.0" }, webDeps: ALIGNED },
{
rootDeps: { ...ALIGNED, outer: "1.0.0", inner: "4.0.0" },
webDeps: ALIGNED,
},
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /\bexpress\b/);
assert.match(out, /^\s+inner$/m);
assert.match(out, /4\.0\.0\s+\(\.\/node_modules\/inner\)/);
assert.match(out, /4\.5\.6\s+\(clients\/web\/node_modules\/inner\)/);
},
);
});
test("main: exits 1 when a configured shared source matches no file", () => {
withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => {
// Drop `core/` from the index — the other sources keep the file count
// nonzero, which is exactly what the old aggregate check missed.
execFileSync("git", ["rm", "-r", "-q", "--cached", "core"], { cwd: dir });
test("main: a package that reaches the program from ONE install is not a candidate", () => {
// `solo` is installed at the root only, so no program ever holds two copies of
// it — its lockfile entries can disagree without TypeScript ever relating two
// declarations. Failing here would be the old derivation's false positive
// (which is what made an allowlist of inert names necessary).
withFixture(
{ rootDeps: { ...ALIGNED, solo: "7.0.0" }, webDeps: ALIGNED },
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 0, out);
assert.doesNotMatch(out, /\bsolo\b/);
},
);
});
test("main: a third install's copy is not dragged into a comparison it never joined (Copilot, #1965 r1)", () => {
// `clients/cli` holds `outer` at a different version, but its own program is
// the only one that loads that copy and no second install meets it there.
// Flattening the candidates to names would compare cli against web's aligned
// pair and fail — naming an install that never took part.
withFixture(
{ cliDeps: { ...ALIGNED, outer: "9.9.9", inner: "9.9.9" } },
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 0, out);
assert.doesNotMatch(out, /clients\/cli/);
},
);
});
test("main: a nested copy is priced from its own lockfile entry (Copilot, #1965 r1)", () => {
// The root reaches `inner` only through `node_modules/outer/node_modules/inner`
// at 9.9.9, while its top-level entry still reads 4.5.6 — the version web
// holds. Pricing the copy from the top-level entry would report the pair as
// agreeing and let a real 9.9.9-vs-4.5.6 program through.
withFixture(
{
rootDeps: { ...ALIGNED, outer: "1.0.0" },
webDeps: ALIGNED,
rootNestedInner: "9.9.9",
},
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /^\s+inner$/m);
assert.match(
out,
/9\.9\.9\s+\(\.\/node_modules\/outer\/node_modules\/inner\)/,
);
assert.match(out, /4\.5\.6\s+\(clients\/web\/node_modules\/inner\)/);
},
);
});
test("main: exits 1 when a client names no tsconfig project", () => {
// No `typecheck` script and no `tsconfig.json` references: none of that
// client's programs is measured, so a skew reaching only them would pass.
withFixture({ webScripts: { build: "vite build" } }, (dir) => {
rmSync(path.join(dir, "clients", "web", "tsconfig.json"));
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /matched no tracked file/);
assert.match(out, /^\s+core$/m);
assert.match(out, /names no tsconfig project/);
});
});
test("main: exits 1 when a program cannot be listed, rather than measuring nothing", () => {
// A broken tsconfig makes `tsc --listFilesOnly` resolve nothing. Reading that
// as "no candidates here" is the gate failing open.
withFixture({ webTsconfig: "{ this is not json" }, (dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /exited non-zero/);
});
});
test("main: exits 1 on a lockfile it cannot read, rather than failing open (Copilot, #1962)", () => {
// A v1 lockfile has `dependencies` and no `packages` table. Treating it as an
// install that simply holds nothing would leave the *root's* zod unopposed
// install that simply holds nothing would leave the *root's* copy unopposed
// and the skew below reported as aligned — the gate failing open.
withFixture(
{
rootDeps: { ...ALIGNED, zod: "4.3.6" },
webDeps: ALIGNED,
rootDeps: { ...ALIGNED, outer: "1.0.0" },
rawWebLock: {
lockfileVersion: 1,
dependencies: { zod: { version: "4.4.3" } },
dependencies: { outer: { version: "1.2.3" } },
},
},
(dir) => {
@ -213,11 +421,9 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", ()
// `""` root npm always writes is not a lockfile this guard can trust.
withFixture(
{
rootDeps: ALIGNED,
webDeps: ALIGNED,
rawWebLock: {
lockfileVersion: 3,
packages: { "node_modules/zod": { version: "4.4.3" } },
packages: { "node_modules/outer": { version: "1.2.3" } },
},
},
(dir) => {
@ -232,22 +438,19 @@ test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", (
// Enrolment is by `package.json`, so a missing lockfile is loud rather than a
// silently absent row. The fixture IS skewed, so dropping the install instead
// would leave the remaining holder unopposed and report aligned.
withFixture(
{ rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED },
(dir) => {
rmSync(path.join(dir, "clients", "web", "package-lock.json"));
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /no lockfile/);
assert.match(out, /clients\/web\/package-lock\.json/);
},
);
withFixture({ rootDeps: { ...ALIGNED, outer: "1.0.0" } }, (dir) => {
rmSync(path.join(dir, "clients", "web", "package-lock.json"));
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /no lockfile/);
assert.match(out, /clients\/web\/package-lock\.json/);
});
});
test("main: exits 1 when the ROOT lockfile is missing", () => {
// The worst variant: the root is the install every shared source resolves
// from, so omitting it could let the guard pass on client locks alone.
withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => {
withFixture({}, (dir) => {
rmSync(path.join(dir, "package-lock.json"));
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
@ -257,8 +460,9 @@ test("main: exits 1 when the ROOT lockfile is missing", () => {
});
test("main: a clients/ dir with no package.json is not an install", () => {
// Enrolling a stray directory would demand a lockfile it should never have.
withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => {
// Enrolling a stray directory would demand a lockfile it should never have,
// and a tsconfig project it has no way to name.
withFixture({}, (dir) => {
mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true });
writeFileSync(
path.join(dir, "clients", "scratch", "notes.md"),
@ -269,11 +473,21 @@ test("main: a clients/ dir with no package.json is not an install", () => {
});
});
test("main: exits 1 when there is no client program to measure at all", () => {
// A moved `clients/` dir leaves the derivation with nothing to look at, and an
// empty candidate set from a broken enumeration reads exactly like a clean
// bill of health.
withFixture({}, (dir) => {
rmSync(path.join(dir, "clients"), { recursive: true, force: true });
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /found no client program to measure/);
});
});
test("main: exits 1 when the root validate no longer runs the sibling guard", () => {
withFixture(
{
rootDeps: ALIGNED,
webDeps: ALIGNED,
// `verify:format-coverage` dropped from the chain: the vouch must fail
// even though the dependency versions themselves are fine.
scripts: {

View file

@ -20,217 +20,101 @@
// `@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.
// The candidate set is DERIVED from what actually enters each `tsc` program
// (#1965): every client tsconfig project is listed with `tsc --listFilesOnly`
// (the shared `lib/tsc-program.mjs` machinery `verify:typecheck-coverage` reads
// too), each resolved `node_modules` file is mapped to its owning install and
// package, and a package that reaches ONE program from TWO installs is a
// candidate. That is exactly the set that can put two structurally-distinct
// copies of a type in front of one type checker — no more, no less.
//
// KNOWN BOUNDARY (#1965): the candidate set covers packages the shared sources
// name *directly*. A package whose declarations reach the program only through
// another package's `.d.ts` is invisible here — `@modelcontextprotocol/sdk` is
// the live example, skewed root 1.29.0 vs `clients/web` 1.30.0 and present in
// web's program from both installs, yet never written in first-party code
// (the shared sources import the split `@modelcontextprotocol/client|core|…`).
// Two derivations were measured for closing this. A lockfile dependency
// closure is unusable — 155 packages, 25 of them skewed, nearly all irrelevant
// tooling (`chai`, `qs`, `iconv-lite`) — and it misses the SDK anyway. Reading
// what actually lands in each program (`tsc --listFilesOnly`, keeping packages
// present under two install roots) is both correct and small: 15 for
// `clients/web`, ~10 once nested duplicates are dropped. That is the right
// derivation and is tracked separately, since it changes what the guard
// measures and surfaces skews needing their own decisions.
// It replaced a derivation that read the packages the shared sources named
// *directly*, which could not see a package whose declarations arrive only
// through another package's `.d.ts`. `@modelcontextprotocol/sdk` was the live
// example: it is never written in first-party code (the shared sources import
// the split `@modelcontextprotocol/client|core|…`), yet 16 of its `.d.ts` files
// land in `clients/web`'s test program, so a second copy under
// `clients/web/node_modules` would have skewed unseen. The other derivation
// measured for #1965 — expanding the direct imports over the lockfiles'
// `dependencies` — was unusable: 155 packages, 25 of them skewed, nearly all
// irrelevant transitive tooling (`chai`, `qs`, `iconv-lite`), and it missed the
// SDK anyway.
//
// Skew among the candidates is then denied by default, with an allowlist of
// packages verified to tolerate it (below). A dependency that starts skewing
// 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, createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { rootReachesScript } from "./lib/npm-scripts.mjs";
import {
clientTsconfigReferences,
crossInstallPackages,
projectListingError,
projectPackageFiles,
resolveLeafProjects,
typecheckProjects,
} from "./lib/tsc-program.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).
// major version*. A rationale 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]*)(?:\/.*)?$/;
//
// **Empty today**, and that is a consequence of the #1965 derivation rather than
// a relaxation. The four former entries were all admitted under the old
// direct-import derivation, which asked only "is this name written in shared
// source and held at two versions somewhere" — a question two installs can
// answer yes to without any program ever seeing both copies:
//
// • `jose` and `@modelcontextprotocol/ext-apps` are declared only at the root
// since #1970, so neither can skew at all now.
// • `react` ships no types of its own, so what lands in a program is
// `@types/react`, and it lands from a single install; the `react` package's
// own files never enter one. `hono` likewise resolves from one install per
// program (`clients/web` in web's node project).
//
// If any of them ever does reach one program from two installs, the guard fails
// and forces the decision then — with the actual version pair in hand, which is
// a better basis for a rationale than a pre-emptive entry.
const TOLERATED_SKEW = new Map();
/**
* 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;
}
// Specifiers are extracted with TypeScript's own `preProcessFile` rather than
// by regex (Copilot, #1962 — raised across three review rounds, and correctly).
// A regex scan gets both directions wrong: it *misses* valid syntax (an
// `import x = require(…)` in a `.cts`, an import-attributes argument, a comment
// between tokens — each a silent miss, so the package never enters the
// candidate set and its skew passes), and it *invents* names from prose, since
// `// adapted from "react"` is indistinguishable from an import to a pattern
// that can't tell code from a comment. Every widening of the regex traded one
// of those failures for the other.
//
// `preProcessFile` is TypeScript's lightweight pre-parse scanner — not a full
// parse and no type checking — and it is exactly built for this: it returns
// every module specifier, handling all import forms, trivia, strings, and
// regex literals correctly, and it never sees a comment as code.
//
// typescript is resolved from `clients/web`, which already carries it; the root
// has no TS dependency of its own. The `createRequire` base is load-bearing —
// a bare `import("typescript")` would resolve relative to `scripts/`, not the
// cwd (the same reason `smoke-web-browser.mjs` resolves playwright this way).
let tsCache;
function typescript() {
if (!tsCache) {
const require_ = createRequire(
path.join(repoRoot, "clients", "web", "package.json"),
);
try {
tsCache = require_("typescript");
} catch (cause) {
// Fail with the cause, not a bare MODULE_NOT_FOUND: the realistic way to
// get here is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1,
// which leaves `clients/web/node_modules` empty. Silently skipping the
// check instead would be worse — an unrun guard guards nothing.
throw new Error(
"verify:dep-lockstep — could not resolve `typescript` from clients/web. " +
"Run `npm install` at the repo root (the postinstall cascade installs each client); " +
"if you set INSPECTOR_SKIP_CLIENT_INSTALL=1, this guard cannot run.",
{ cause },
);
}
}
return tsCache;
}
/**
* The package(s) a `/// <reference types="x" />` directive can resolve to
* (Copilot, #1962). Such a directive pulls in declarations exactly like an
* import does, but TypeScript reports it separately from `importedFiles`, so
* reading only the latter would let a referenced package skew unseen.
* Installed versions in a parsed lockfile, keyed by the **install path** npm
* writes — `node_modules/zod`, `node_modules/a/node_modules/zod`.
*
* Both candidates are returned because the directive name is the *type* name,
* not the package: `node` resolves to `@types/node`, while a package shipping
* its own declarations resolves to itself. Returning both over-approximates,
* which is the safe direction — whichever isn't installed drops out. A scoped
* name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention.
* Keyed by path rather than by package name so a version is read from the exact
* copy a program resolved (Copilot, #1965 r1). Reading only top-level entries
* would mean a nested copy that entered the program got compared against
* whatever sits at the install's top level — a different version, or nothing at
* all — and a real pair could pass. Nested copies are still not a *candidate* on
* their own (`classifyModulePath` folds them onto their outermost install); this
* is about pricing a copy correctly once a program has loaded it.
*/
export function typeReferencePackageNames(directive) {
const name = packageNameOf(directive);
if (!name) return [];
const scoped = /^@([^/]+)\/(.+)$/.exec(name);
const typesName = scoped
? `@types/${scoped[1]}__${scoped[2]}`
: `@types/${name}`;
return [name, typesName];
}
/**
* Every third-party package name whose declarations a blob of TypeScript source
* pulls in — via an import of any form, or a triple-slash type reference.
* Over-approximating is safe (a name absent from every lockfile contributes
* nothing downstream — `@inspector/core` is a build-time alias, not a package,
* and drops out that way); under-approximating is not, since a missed package
* never enters the candidate set and its skew would pass silently.
*/
export function importedPackageNames(source) {
const names = new Set();
// (source, readImportFiles, detectJavaScriptImports) — the latter two make it
// report `require(…)` and dynamic imports as well as static ones.
const { importedFiles, typeReferenceDirectives } =
typescript().preProcessFile(source, true, true);
for (const { fileName } of importedFiles) {
const name = packageNameOf(fileName);
if (name) names.add(name);
}
for (const { fileName } of typeReferenceDirectives ?? [])
for (const name of typeReferencePackageNames(fileName)) 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) {
export function lockVersionsByPath(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);
if (!entryPath.startsWith("node_modules/")) continue;
if (typeof entry?.version !== "string") continue;
versions.set(entryPath, entry.version);
}
return versions;
}
@ -241,7 +125,7 @@ export function topLevelLockVersions(lock) {
* the root project.
*
* This is checked rather than tolerated because the gate is deny-by-default and
* `topLevelLockVersions` returns an empty map for anything else. An unreadable
* `lockVersionsByPath` returns an empty map for anything else. An unreadable
* lockfile would otherwise contribute no holders, and a real skew among the
* remaining installs would be reported as aligned — the gate failing *open*,
* which is the one way it must never fail (Copilot, #1962). A v1 lockfile
@ -262,22 +146,48 @@ export function hasReadableLockShape(lock) {
}
/**
* 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.
* Price each co-occurrence and keep the ones whose copies disagree.
*
* `found` is {@link crossInstallPackages}' output —
* `Map<name, Map<program, Map<installRoot, Set<entryPath>>>>` — and `versions`
* maps an install dir to that install's {@link lockVersionsByPath}. A package is
* skewed when the copies **one program** loaded, from two different installs,
* carry more than one version. Only the installs that actually met in that
* program are compared: a third install's copy that no program loads is not
* evidence of anything (Copilot, #1965 r1).
*
* Returns `{ skewed, unresolved }`, sorted by name. `unresolved` names a copy
* whose lockfile entry is missing — the tree and the lockfile disagree, so the
* comparison cannot be trusted and the caller must fail rather than skip it.
*/
export function findSkew(candidates, installs) {
export function findSkew(found, versions) {
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 });
const unresolved = [];
for (const name of [...found.keys()].sort()) {
const occurrences = [];
for (const [program, byRoot] of found.get(name)) {
const holders = [];
for (const [dir, entryPaths] of byRoot)
for (const entryPath of entryPaths) {
const version = versions.get(dir)?.get(entryPath);
if (version === undefined) unresolved.push({ name, dir, entryPath });
else holders.push({ dir, entryPath, version });
}
if (new Set(holders.map((h) => h.version)).size > 1)
occurrences.push({ program, holders });
}
if (occurrences.length > 0) skewed.push({ name, occurrences });
}
return skewed;
return { skewed, unresolved };
}
/** Every holder across a skewed package's occurrences, deduped by install + path. */
export function skewHolders(entry) {
const byKey = new Map();
for (const { holders } of entry.occurrences)
for (const holder of holders)
byKey.set(`${holder.dir}|${holder.entryPath}`, holder);
return [...byKey.values()];
}
/**
@ -302,7 +212,7 @@ export function majorOf(version) {
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)));
const majors = new Set(skewHolders(s).map((h) => majorOf(h.version)));
return majors.size === 1 && !majors.has(null);
};
return {
@ -311,84 +221,124 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) {
};
}
// 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.
* The tsconfig projects whose programs a client contributes, from its own
* `package.json` scripts and its root `tsconfig.json` `references` — the same
* two enrollment paths `verify:typecheck-coverage` uses, so the two guards
* measure the same programs.
*
* A `neutered` project (its `typecheck` command carries `--noCheck` /
* `--listFilesOnly`) counts here even though its sibling guard rejects it:
* whether that pass type-checks is that guard's question, while this one only
* asks what the program *resolves*, and a program still resolves its imports
* either way. Dropping them would shrink what this guard measures on the
* strength of a defect the other guard is already failing on.
*/
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}/`));
export function clientProjects(scripts, references) {
if (typeof scripts?.typecheck === "string") {
const { projects, neutered } = typecheckProjects(scripts);
return [...projects, ...neutered.map((n) => n.project)];
}
return references;
}
/**
* Which configured shared sources contributed no file to `files`. Each dir and
* each individually-named file must be represented; an aggregate count can't
* see one of them going missing, because the others keep the total nonzero.
* The `clients/*` directories that are real installs — one with a
* `package.json`. Enrolment is by manifest, NOT by the presence of a lockfile
* (Copilot, #1962): filtering on the lockfile made a missing one silently drop
* that install from the comparison.
*/
export function sourcesWithNoFiles(
files,
dirs = SHARED_SOURCE_DIRS,
named = SHARED_SOURCE_FILES,
) {
const missingDirs = dirs.filter(
(dir) => !files.some((f) => f.startsWith(`${dir}/`)),
);
const missingNamed = named.filter((name) => !files.includes(name));
return [...missingDirs, ...missingNamed];
}
/** 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);
function clientDirs() {
const dir = path.join(repoRoot, "clients");
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => `clients/${e.name}`)
.filter((rel) => existsSync(path.join(repoRoot, rel, "package.json")))
.sort();
}
/**
* 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`).
* The installs to compare: the repo root plus every `clients/*` install.
* Discovered from disk rather than listed, so a new client is covered without
* editing this guard (the same enrollment style as `verify:typecheck-coverage`).
* The root is always enrolled: it is this repo, so its manifest is a given.
*/
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()
: [];
// Enrolment is by `package.json` — an install we are meant to compare —
// NOT by the presence of a lockfile (Copilot, #1962). Filtering on the
// lockfile made a missing one silently drop that install from the
// comparison; for the root, the install every shared source resolves from,
// that meant the guard could report success from client locks alone. A
// missing lockfile is now a loud failure in `main`, not an absent row. The
// root is always enrolled: it is this repo, so its manifest is a given.
return ["."].concat(
clients.filter((dir) =>
existsSync(path.join(repoRoot, dir, "package.json")),
),
);
return ["."].concat(clientDirs());
}
/**
* List every client program and reduce it to the packages that reach one program
* from two installs. Returns `{ found, programs, problems }` — `found` is the
* {@link crossInstallPackages} map, `programs` the labels listed (for the
* success line), `problems` any reason the measurement itself can't be trusted.
*
* A program that resolves NO installed file at all is a problem, not an empty
* result: every real program pulls in at least the toolchain's own `.d.ts` from
* some `node_modules`, so an empty listing means `tsc` never ran (a missing
* install) or the config is broken. Treating it as "no candidates here" is the
* gate failing open.
*/
function deriveCandidates() {
const problems = [];
const programs = [];
for (const clientDir of clientDirs()) {
let scripts;
try {
scripts = JSON.parse(
readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"),
).scripts;
} catch (cause) {
throw new Error(
`verify:dep-lockstep — could not parse ${clientDir}/package.json.`,
{ cause },
);
}
const projects = clientProjects(
scripts,
clientTsconfigReferences(clientDir),
);
if (projects.length === 0) {
problems.push(
`${clientDir}: names no tsconfig project (no \`typecheck\` script, no \`tsconfig.json\` references) — none of its programs is measured.`,
);
continue;
}
const leaves = new Set();
for (const project of projects)
for (const leaf of resolveLeafProjects(clientDir, project))
leaves.add(leaf);
for (const leaf of leaves) {
// A failed config is not a program: tsc still prints a file list, but it
// is whatever its fallback resolved rather than what the project declares,
// so measuring it would narrow the candidate set for a reason that has
// nothing to do with the dependency tree.
const error = projectListingError(clientDir, leaf);
if (error) {
problems.push(
`${clientDir}: \`tsc -p ${leaf} --listFilesOnly\` exited non-zero — ${error.split("\n")[0]}`,
);
continue;
}
const files = projectPackageFiles(clientDir, leaf);
if (files.size === 0) {
problems.push(
`${clientDir}: \`tsc -p ${leaf} --listFilesOnly\` resolved no installed file — the program could not be listed.`,
);
continue;
}
programs.push({ label: `${clientDir}/${leaf}`, files });
}
}
// No program at all means the enumeration itself broke (a moved `clients/`
// dir), not that there is nothing to check — and an empty candidate set from a
// broken enumeration is indistinguishable from a clean bill of health.
if (programs.length === 0 && problems.length === 0)
problems.push(
"found no client program to measure — every `clients/*` install must contribute at least one tsconfig project.",
);
return { found: crossInstallPackages(programs), programs, problems };
}
/**
@ -411,30 +361,19 @@ export function main() {
process.exit(1);
}
const files = sharedSourceFiles();
// Per-source, not an aggregate count (Copilot, #1962): `vitest.shared.mts`
// alone keeps the total nonzero, so a moved or renamed `core/` would leave
// the guard checking a near-empty candidate set and passing. Every configured
// source must contribute, or the enumeration is broken.
const empty = sourcesWithNoFiles(files);
if (empty.length > 0) {
const { found, programs, problems } = deriveCandidates();
if (problems.length > 0) {
console.error(
`verify:dep-lockstep — ${empty.length} configured shared source(s) matched no tracked file:\n`,
`verify:dep-lockstep — ${problems.length} program(s) could not be measured:\n`,
);
for (const source of empty) console.error(` ${source}`);
for (const problem of problems) console.error(` ${problem}`);
console.error(
"\nThe guard would derive its candidates from an incomplete set and pass on skew it should catch." +
"\nA path was moved or renamed — fix SHARED_SOURCE_DIRS / SHARED_SOURCE_FILES in this file.",
"\nThe candidate set is derived from what each `tsc` program resolves, so an unmeasured program" +
"\nmeans real skew could pass unseen. Run `npm install` at the repo root (the postinstall cascade" +
"\ninstalls each client), and see `verify:typecheck-coverage` for the tsconfig-project enrollment rules.",
);
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();
// A missing lockfile is a failure, not a skipped install: dropping one would
@ -488,34 +427,58 @@ export function main() {
process.exit(1);
}
const installs = locks.map(({ dir, lock }) => ({
dir,
versions: topLevelLockVersions(lock),
}));
const versions = new Map(
locks.map(({ dir, lock }) => [dir, lockVersionsByPath(lock)]),
);
const { failures, ignored } = partitionSkew(findSkew(candidates, installs));
const { skewed, unresolved } = findSkew(found, versions);
// A copy the program loaded but the lockfile doesn't list means the installed
// tree and the lockfile disagree — the comparison would be reading a version
// that isn't the one on disk, so refuse it rather than skip the copy.
if (unresolved.length > 0) {
console.error(
`verify:dep-lockstep — ${unresolved.length} resolved package(s) have no lockfile entry:\n`,
);
for (const { name, dir, entryPath } of unresolved)
console.error(` ${name} ${dir}/${entryPath}`);
console.error(
"\nThe program loaded these, so their versions decide the comparison — but the install's lockfile" +
"\ndoesn't list them, which means the tree and the lockfile disagree. Run `npm install` at the repo" +
"\nroot to re-sync every install.",
);
process.exit(1);
}
const { failures, ignored } = partitionSkew(skewed);
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) {
for (const failure 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);
const listed = TOLERATED_SKEW.has(failure.name);
anyListed ||= listed;
console.error(
` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`,
` ${failure.name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`,
);
for (const { dir, version } of holders)
console.error(` ${version} (${dir})`);
// Report per program: which copies met, and where. The program is the
// whole reason the package is a candidate, and naming only the versions
// would leave the reader unable to check the claim — or to tell a nested
// copy from the install's top-level one.
for (const { program, holders } of failure.occurrences) {
console.error(` in ${program}`);
for (const { dir, entryPath, version } of holders)
console.error(` ${version} (${dir}/${entryPath})`);
}
}
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" +
"\nEach of these reaches a single `tsc` program from two installs (a client's own sources resolve" +
"\nfrom the client install, the shared `core/` + `test-servers/src` they pull in resolve from the" +
"\nroot), so 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(
@ -534,7 +497,8 @@ export function main() {
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}.`,
`verify:dep-lockstep — OK: ${found.size} install-crossing dependencies agree across ${dirs.length} installs${note} ` +
`(derived from ${programs.length} tsc programs).`,
);
}

View file

@ -3,265 +3,68 @@
// change that relaxes one is visible as a deleted assertion rather than a quiet
// behavior shift. Run via `npm run test:scripts` (node:test; the root has no
// vitest harness).
//
// The candidate derivation itself lives in `lib/tsc-program.mjs` (shared with
// `verify:typecheck-coverage` since #1965) and is covered by
// `lib/tsc-program.test.mjs`; what stays here is the lockfile comparison and the
// client-enrollment rule this guard owns.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
clientProjects,
findSkew,
hasReadableLockShape,
importedPackageNames,
isSharedSourceFile,
lockVersionsByPath,
majorOf,
packageNameOf,
partitionSkew,
sourcesWithNoFiles,
topLevelLockVersions,
typeReferencePackageNames,
} from "./verify-dep-lockstep.mjs";
test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => {
// `.mts`/`.cts` are gated by `verify:format-coverage` and
// `verify:typecheck-coverage` too. None exist under the shared trees today,
// so dropping them here would go unnoticed until a new shared dependency
// arrived through one — the skew this guard exists to catch (Copilot, #1962).
for (const ext of [".ts", ".tsx", ".mts", ".cts"]) {
assert.equal(isSharedSourceFile(`core/mcp/thing${ext}`), true, ext);
assert.equal(
isSharedSourceFile(`test-servers/src/thing${ext}`),
true,
`test-servers ${ext}`,
);
}
});
test("isSharedSourceFile: non-TS files and other trees are excluded", () => {
const rejected = [
"core/README.md", // not TypeScript
"core/mcp/data.json",
"clients/web/src/App.tsx", // a client's own sources resolve from the client
"scripts/verify-dep-lockstep.mjs",
"test-servers/configs/modern-http.json",
// Path-boundary anchoring: a sibling dir whose name merely starts with a
// shared dir's name must not be swept in.
"core-internal/thing.ts",
"test-servers/src-legacy/thing.ts",
];
for (const file of rejected)
assert.equal(isSharedSourceFile(file), false, file);
});
test("sourcesWithNoFiles: each configured source must contribute (Copilot, #1962)", () => {
const dirs = ["core", "test-servers/src"];
const named = ["vitest.shared.mts"];
const complete = [
"core/mcp/a.ts",
"test-servers/src/b.ts",
"vitest.shared.mts",
];
assert.deepEqual(sourcesWithNoFiles(complete, dirs, named), []);
// The failure an aggregate count can't see: `core/` moved, but the other two
// sources keep `files.length` nonzero, so the guard would derive candidates
// from an incomplete set and pass on skew it should catch.
test("clientProjects: a `typecheck` script's projects win over references", () => {
// Both enrollment paths exist (cli/tui/launcher declare `typecheck`,
// `clients/web` is a `tsc -b` solution) and this guard must measure the same
// programs as `verify:typecheck-coverage`, which prefers the script.
assert.deepEqual(
sourcesWithNoFiles(
["test-servers/src/b.ts", "vitest.shared.mts"],
dirs,
named,
clientProjects(
{
typecheck: "tsc --noEmit -p tsconfig.json && tsc -p tsconfig.test.json",
},
["./ignored.json"],
),
["core"],
["tsconfig.json", "tsconfig.test.json"],
);
});
test("clientProjects: a reference client is measured through its references", () => {
assert.deepEqual(
sourcesWithNoFiles(["core/mcp/a.ts", "test-servers/src/b.ts"], dirs, named),
["vitest.shared.mts"],
clientProjects({ build: "tsc -b && vite build" }, ["./a.json"]),
["./a.json"],
);
assert.deepEqual(sourcesWithNoFiles([], dirs, named), [
"core",
"test-servers/src",
"vitest.shared.mts",
]);
// Neither path available — the caller reports it rather than measuring nothing.
assert.deepEqual(clientProjects({ build: "vite build" }, []), []);
});
test("sourcesWithNoFiles: a prefix sibling does not vouch for a dir", () => {
// `core-internal/` starts with `core` but is not it — the boundary check has
// to be on a path separator, or a renamed dir would look present.
assert.deepEqual(sourcesWithNoFiles(["core-internal/a.ts"], ["core"], []), [
"core",
]);
test("clientProjects: a NEUTERED project still contributes its program (#1965)", () => {
// `--noCheck` stops that pass type-checking, which is the sibling guard's
// complaint; the program still resolves its imports, and dropping it here
// would shrink what THIS guard measures on the strength of that other defect.
assert.deepEqual(
clientProjects(
{
typecheck:
"tsc -p tsconfig.json --noCheck && tsc -p tsconfig.test.json",
},
[],
).sort(),
["tsconfig.json", "tsconfig.test.json"],
);
});
test("packageNameOf: bare names, scopes, and subpaths", () => {
const cases = [
["zod", "zod"],
["zod/v4", "zod"], // subpath dropped — one package, one version
["@modelcontextprotocol/client", "@modelcontextprotocol/client"],
["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"],
["react-dom/client", "react-dom"],
];
for (const [input, expected] of cases)
assert.equal(packageNameOf(input), expected, input);
});
test("packageNameOf: non-packages are rejected", () => {
// Relative/absolute paths, built-ins with and without the `node:` prefix,
// protocol specifiers, and prose that follows the word `from` in a comment.
const rejected = [
"./foo",
"../core/mcp",
"/abs/path",
"fs",
"path",
"node:crypto",
"node:test",
"file:",
"data:text/plain,x",
"cwd omitted",
"",
];
for (const input of rejected)
assert.equal(packageNameOf(input), null, JSON.stringify(input));
assert.equal(packageNameOf(undefined), null);
});
test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, #1962)", () => {
// Under-approximating is the dangerous direction: a package the scan misses
// never enters the candidate set, so its skew passes the guard silently.
// `.cts` sources in the shared trees use `import x = require(…)` as ordinary
// syntax, and a dynamic import may carry import attributes or a static
// template literal — none of which the original three patterns matched.
const source = `
import express = require("express");
const yaml = require("yaml");
const a = await import("undici", { with: { type: "json" } });
const b = await import(\`jose\`);
`;
assert.deepEqual([...importedPackageNames(source)].sort(), [
"express",
"jose",
"undici",
"yaml",
]);
});
test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () => {
// TypeScript allows a comment anywhere whitespace is legal, so all of these
// are valid imports. Missing one is the dangerous direction: the package
// never enters the candidate set and its skew passes the guard silently.
const source = `
import { a } from /* explanation */ "express";
const b = await import(/* webpackIgnore: true */ "undici");
const c = require(/* lazy */ "yaml");
import /* side effect */ "pino";
`;
assert.deepEqual([...importedPackageNames(source)].sort(), [
"express",
"pino",
"undici",
"yaml",
]);
});
test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => {
// `//` runs to end-of-line and is legal in every position a block comment is,
// so a specifier can sit on the next line and these are still valid imports.
const source = [
"import { a } from // reason",
' "express";',
"const b = await import(// lazy",
' "undici");',
"const c = require(// lazy",
' "yaml");',
].join("\n");
assert.deepEqual([...importedPackageNames(source)].sort(), [
"express",
"undici",
"yaml",
]);
});
test("importedPackageNames: static, side-effect, and dynamic forms; builtins and relatives dropped", () => {
const source = `
import { z } from "zod/v4";
export type { Foo } from '@modelcontextprotocol/core';
import "./side-effect.css";
import "pino";
const mod = await import("chokidar");
import fs from "node:fs";
import { helper } from "../local/helper";
`;
assert.deepEqual([...importedPackageNames(source)].sort(), [
"@modelcontextprotocol/core",
"chokidar",
"pino",
"zod",
]);
});
test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => {
// A `/// <reference types="x" />` pulls in declarations exactly like an
// import, but TypeScript reports it in `typeReferenceDirectives`, not
// `importedFiles` — so reading only the latter let a referenced package skew
// unseen. `path` references name a file, not a package, and are ignored.
const source = [
'/// <reference types="node" />',
'/// <reference types="express" />',
'/// <reference path="./local.d.ts" />',
'import { z } from "zod";',
].join("\n");
assert.deepEqual([...importedPackageNames(source)].sort(), [
"@types/express",
"@types/node",
"express",
"node",
"zod",
]);
});
test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => {
// The directive names a *type*, not a package: `node` resolves to
// `@types/node`, while a package shipping its own declarations resolves to
// itself. Returning both over-approximates, the safe direction — whichever
// isn't installed drops out downstream.
assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]);
// Scoped names mangle with a double underscore, TypeScript's convention.
assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [
"@scope/pkg",
"@types/scope__pkg",
]);
assert.deepEqual(typeReferencePackageNames("./relative"), []);
assert.deepEqual(typeReferencePackageNames(""), []);
});
test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => {
// The regex scan this replaced could not tell code from a comment, so
// `// adapted from "react"` added `react` to the candidate set — and if that
// installed package were skewed, an unrelated comment would fail `validate`.
// These use REAL package names, which is the case the old prose test missed:
// it only passed because `cwd omitted` isn't a valid package name.
const source = `
// adapted from "react"
/** Mirrors the behavior of "express", see require("yaml") below. */
/** The excluded set derived from \\\`hono\\\`-style paths. */
// const disabled = await import("undici");
import { z } from "zod";
`;
assert.deepEqual([...importedPackageNames(source)], ["zod"]);
});
test("importedPackageNames: a specifier inside a string literal is not an import", () => {
const source = `
const msg = 'run require("chokidar") to load it';
const re = /"jose"/;
import { z } from "zod";
`;
assert.deepEqual([...importedPackageNames(source)], ["zod"]);
});
test("topLevelLockVersions: nested duplicates are ignored", () => {
// A nested `node_modules/a/node_modules/b` is npm resolving a transitive
// conflict *inside* one install — routine, and not the cross-install skew
// this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2
// is the live example).
test("lockVersionsByPath: keyed by install path, nested entries included (Copilot, #1965 r1)", () => {
// Keyed by PATH, not by package name: a nested copy that entered a program has
// to be priced from its own entry. Reading only `node_modules/<pkg>` would
// compare it against the install's top-level copy — a different version, or
// none — and a real pair could pass.
const lock = {
packages: {
"": { name: "root" },
@ -272,18 +75,173 @@ test("topLevelLockVersions: nested duplicates are ignored", () => {
"node_modules/no-version": { resolved: "https://example.test/x.tgz" },
},
};
assert.deepEqual([...topLevelLockVersions(lock)].sort(), [
["@modelcontextprotocol/client", "2.0.0-beta.5"],
["yaml", "2.9.0"],
["zod", "4.4.3"],
assert.deepEqual([...lockVersionsByPath(lock)].sort(), [
["node_modules/@modelcontextprotocol/client", "2.0.0-beta.5"],
["node_modules/cosmiconfig/node_modules/yaml", "1.10.3"],
["node_modules/yaml", "2.9.0"],
["node_modules/zod", "4.4.3"],
]);
});
test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => {
test("lockVersionsByPath: a malformed or empty lockfile yields nothing", () => {
// Safe as a pure helper *because* `hasReadableLockShape` rejects these before
// any comparison — an empty map reaching `findSkew` is the fail-open path.
for (const lock of [undefined, null, {}, { packages: {} }])
assert.equal(topLevelLockVersions(lock).size, 0);
assert.equal(lockVersionsByPath(lock).size, 0);
});
/** `crossInstallPackages`-shaped input: name → program → install → entry paths. */
const occurrence = (name, program, byRoot) =>
new Map([
[
name,
new Map([
[
program,
new Map(
Object.entries(byRoot).map(([dir, paths]) => [dir, new Set(paths)]),
),
],
]),
],
]);
const lockPaths = (byDir) =>
new Map(
Object.entries(byDir).map(([dir, entries]) => [
dir,
new Map(Object.entries(entries)),
]),
);
test("findSkew: reports the copies one program loaded, with their paths", () => {
const { skewed, unresolved } = findSkew(
occurrence("zod", "clients/web/tsconfig.test.json", {
".": ["node_modules/zod"],
"clients/web": ["node_modules/zod"],
}),
lockPaths({
".": { "node_modules/zod": "4.3.6" },
"clients/web": { "node_modules/zod": "4.4.3" },
}),
);
assert.deepEqual(unresolved, []);
assert.deepEqual(skewed, [
{
name: "zod",
occurrences: [
{
program: "clients/web/tsconfig.test.json",
holders: [
{ dir: ".", entryPath: "node_modules/zod", version: "4.3.6" },
{
dir: "clients/web",
entryPath: "node_modules/zod",
version: "4.4.3",
},
],
},
],
},
]);
});
test("findSkew: only the installs that MET in a program are compared (Copilot, #1965 r1)", () => {
// `clients/cli` holds a different zod, but no program loads it beside another
// copy — nothing has to relate the two, so it is not a finding, and naming cli
// in a diagnostic about web's program would be wrong as well as noisy.
const { skewed } = findSkew(
occurrence("zod", "clients/web/tsconfig.test.json", {
".": ["node_modules/zod"],
"clients/web": ["node_modules/zod"],
}),
lockPaths({
".": { "node_modules/zod": "4.4.3" },
"clients/web": { "node_modules/zod": "4.4.3" },
"clients/cli": { "node_modules/zod": "4.3.6" },
}),
);
assert.deepEqual(skewed, []);
});
test("findSkew: a nested copy is priced from its own entry (Copilot, #1965 r1)", () => {
// The root loaded zod through `a`'s nested copy. Pricing it from the root's
// TOP-LEVEL entry (4.4.3, aligned with web) would report the pair as agreeing.
const { skewed } = findSkew(
occurrence("zod", "clients/web/tsconfig.test.json", {
".": ["node_modules/a/node_modules/zod"],
"clients/web": ["node_modules/zod"],
}),
lockPaths({
".": {
"node_modules/zod": "4.4.3",
"node_modules/a/node_modules/zod": "3.1.0",
},
"clients/web": { "node_modules/zod": "4.4.3" },
}),
);
assert.deepEqual(
skewed[0].occurrences[0].holders.map((h) => `${h.dir}:${h.version}`),
[".:3.1.0", "clients/web:4.4.3"],
);
});
test("findSkew: agreement is not skew", () => {
const { skewed, unresolved } = findSkew(
occurrence("zod", "p", {
".": ["node_modules/zod"],
"clients/web": ["node_modules/zod"],
}),
lockPaths({
".": { "node_modules/zod": "4.4.3" },
"clients/web": { "node_modules/zod": "4.4.3" },
}),
);
assert.deepEqual(skewed, []);
assert.deepEqual(unresolved, []);
});
test("findSkew: a copy with no lockfile entry is reported, not skipped", () => {
// Skipping it would drop a holder from the comparison and could report a real
// skew as agreement — the gate failing open.
const { skewed, unresolved } = findSkew(
occurrence("zod", "p", {
".": ["node_modules/zod"],
"clients/web": ["node_modules/zod"],
}),
lockPaths({ ".": { "node_modules/zod": "4.4.3" }, "clients/web": {} }),
);
assert.deepEqual(unresolved, [
{ name: "zod", dir: "clients/web", entryPath: "node_modules/zod" },
]);
assert.deepEqual(skewed, []);
});
test("findSkew: results are sorted by package name", () => {
const found = new Map([
...occurrence("zod", "p", {
".": ["node_modules/zod"],
"clients/web": ["node_modules/zod"],
}),
...occurrence("hono", "p", {
".": ["node_modules/hono"],
"clients/web": ["node_modules/hono"],
}),
]);
const { skewed } = findSkew(
found,
lockPaths({
".": { "node_modules/zod": "1.0.0", "node_modules/hono": "1.0.0" },
"clients/web": {
"node_modules/zod": "2.0.0",
"node_modules/hono": "2.0.0",
},
}),
);
assert.deepEqual(
skewed.map((s) => s.name),
["hono", "zod"],
);
});
test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot, #1962)", () => {
@ -321,89 +279,32 @@ test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot
assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock));
});
test("findSkew: reports a package held at two versions", () => {
const installs = [
{ dir: ".", versions: new Map([["zod", "4.3.6"]]) },
{ dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) },
{ dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) },
];
assert.deepEqual(findSkew(new Set(["zod"]), installs), [
{
name: "zod",
holders: [
{ dir: ".", version: "4.3.6" },
{ dir: "clients/web", version: "4.4.3" },
{ dir: "clients/cli", version: "4.4.3" },
],
},
]);
});
test("findSkew: agreement and single-install packages are not skew", () => {
const installs = [
{
dir: ".",
versions: new Map([
["zod", "4.4.3"],
["express", "5.2.1"],
]),
},
{ dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) },
];
// `express` lives in one install only, so it cannot skew — a package absent
// from a client is not a finding.
assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []);
});
test("findSkew: a candidate in no lockfile is inert", () => {
// `@inspector/core` is a build-time alias, not a package; the scan picks it
// up and it must drop out here rather than error.
const installs = [
{ dir: ".", versions: new Map([["zod", "4.4.3"]]) },
{ dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) },
];
assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []);
});
test("findSkew: results are sorted by package name", () => {
const installs = [
{
dir: ".",
versions: new Map([
["zod", "1.0.0"],
["hono", "1.0.0"],
]),
},
{
dir: "clients/web",
versions: new Map([
["zod", "2.0.0"],
["hono", "2.0.0"],
]),
},
];
assert.deepEqual(
findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name),
["hono", "zod"],
);
});
test("partitionSkew: the allowlist is by name, not by version pair", () => {
// So an ordinary patch float within a tolerated package does not churn the
// allowlist, while any *unlisted* package that starts skewing still fails.
const skewed = [
{
name: "react",
holders: [
{ dir: ".", version: "19.2.7" },
{ dir: "clients/web", version: "19.2.8" },
occurrences: [
{
program: "p",
holders: [
{ dir: ".", version: "19.2.7" },
{ dir: "clients/web", version: "19.2.8" },
],
},
],
},
{
name: "zod",
holders: [
{ dir: ".", version: "4.3.6" },
{ dir: "clients/web", version: "4.4.3" },
occurrences: [
{
program: "p",
holders: [
{ dir: ".", version: "4.3.6" },
{ dir: "clients/web", version: "4.4.3" },
],
},
],
},
];
@ -420,7 +321,14 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => {
});
test("partitionSkew: deny by default — nothing tolerated fails everything", () => {
const skewed = [{ name: "zod", holders: [{ dir: ".", version: "1.0.0" }] }];
const skewed = [
{
name: "zod",
occurrences: [
{ program: "p", holders: [{ dir: ".", version: "1.0.0" }] },
],
},
];
assert.equal(partitionSkew(skewed, new Map()).failures.length, 1);
});
@ -432,18 +340,28 @@ test("partitionSkew: the allowlist tolerates skew only within a major (Copilot,
const withinMajor = [
{
name: "react",
holders: [
{ dir: ".", version: "19.2.7" },
{ dir: "clients/web", version: "19.2.8" },
occurrences: [
{
program: "p",
holders: [
{ dir: ".", version: "19.2.7" },
{ dir: "clients/web", version: "19.2.8" },
],
},
],
},
];
const acrossMajor = [
{
name: "react",
holders: [
{ dir: ".", version: "18.3.1" },
{ dir: "clients/web", version: "19.2.8" },
occurrences: [
{
program: "p",
holders: [
{ dir: ".", version: "18.3.1" },
{ dir: "clients/web", version: "19.2.8" },
],
},
],
},
];
@ -458,9 +376,14 @@ test("partitionSkew: an unparseable version can't be proven same-major, so it fa
const skewed = [
{
name: "react",
holders: [
{ dir: ".", version: "19.2.7" },
{ dir: "clients/web", version: "next" },
occurrences: [
{
program: "p",
holders: [
{ dir: ".", version: "19.2.7" },
{ dir: "clients/web", version: "next" },
],
},
],
},
];
@ -479,13 +402,3 @@ test("majorOf: prerelease and build metadata are irrelevant", () => {
for (const bad of ["next", "", undefined, null, "v4.4.3"])
assert.equal(majorOf(bad), null, JSON.stringify(bad));
});
test("isSharedSourceFile: individually-named shared files are included (Copilot, #1962)", () => {
// `vitest.shared.mts` is root-owned, imported by every client's vitest
// config, and already treated as shared by `verify:typecheck-coverage`. It
// imports only Node built-ins today, which is why omitting it would go
// unnoticed until a third-party import appeared there and skewed.
assert.equal(isSharedSourceFile("vitest.shared.mts"), true);
// Still anchored: a same-named file nested elsewhere is not the shared one.
assert.equal(isSharedSourceFile("clients/web/vitest.shared.mts"), false);
});

View file

@ -47,6 +47,18 @@ import {
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.
import {
clientTsconfigReferences,
isDisablingFlag,
isTsc,
projectSourceFiles,
resolveLeafProjects,
typecheckProjects,
} from "./lib/tsc-program.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@ -70,15 +82,6 @@ const EXEMPT = new Map();
export const isRequiredSource = (rel) =>
/\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel);
// 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. Shared by the
// `typecheck`-script path and the reference (`tsc -b`) path.
export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t);
/**
* 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
@ -256,51 +259,6 @@ export function testScriptProblems(scripts, testFiles) {
return problems;
}
/**
* The `references` paths declared in the tsconfig at repo-relative `tsconfigRel`
* (a `tsc -b` solution config), or `[]` if it has none / isn't readable. 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) — this guard
* enrolls it through these instead of exempting the whole tree.
*/
export function clientTsconfigReferences(clientDir) {
return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json"));
}
/**
* 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
@ -388,64 +346,6 @@ function nodeClients() {
return { clients, problems };
}
/**
* 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 guard while
* checking nothing. The config-file form (`noCheck` set in the tsconfig) is
* caught separately by {@link projectDisablesChecking}.
*
* A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists
* nothing itself; {@link projectFiles} expands it to its references, so this
* form is measured whether it reaches here (a `typecheck: "tsc -b"`) or the
* dedicated reference path (`clients/web`, which declares no `typecheck`).
*
* 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 };
}
/**
* 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
@ -466,116 +366,11 @@ function projectDisablesChecking(clientDir, project) {
}
}
/**
* Repo-relative POSIX paths of the 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 client-relative
* paths. Cached: `resolveLeafProjects` and `projectFiles` both list a project.
*/
const rawFilesCache = new Map();
function rawProjectFiles(clientDir, project) {
const key = `${clientDir}|${project}`;
const cached = rawFilesCache.get(key);
if (cached) return cached;
const absClient = path.join(repoRoot, clientDir);
let stdout;
try {
stdout = execFileSync(
"npx",
["--no-install", "tsc", "-p", project, "--listFilesOnly"],
{ cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
} 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 a coverage gap. Echo the
// diagnostic — since this guard runs before any client's own `typecheck`,
// it's the first place a bad `-p` config surfaces, and without the reason
// the resulting "file in no project" 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 : "");
const diagnostic = streams
.split("\n")
.filter((l) => /error TS\d+/.test(l))
.join("\n")
.trim();
console.warn(
`verify:typecheck-coverage — \`tsc -p ${project}\` (in ${clientDir}) exited non-zero:\n${diagnostic || "(no diagnostic captured)"}\n`,
);
}
const covered = new Set();
for (const line of stdout.split("\n")) {
const abs = line.trim();
if (!abs) continue;
const rel = path.relative(repoRoot, abs);
if (rel.startsWith("..") || rel.includes("node_modules")) continue;
covered.add(rel.split(path.sep).join("/"));
}
rawFilesCache.set(key, covered);
return covered;
}
/**
* 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 just inside
* `projectFiles`) is what lets BOTH coverage and the non-inertness check follow
* the same graph, so a `noCheck` in a *referenced* project is caught no matter
* which enrollment path harvested the solution.
*/
export function resolveLeafProjects(clientDir, project, seen = new Set()) {
if (seen.has(project)) return [];
seen.add(project);
// Lists 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 too.)
if (rawProjectFiles(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,
),
);
}
/** 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 rawProjectFiles(clientDir, leaf)) covered.add(f);
for (const f of projectSourceFiles(clientDir, leaf)) covered.add(f);
return covered;
}

View file

@ -2,24 +2,22 @@
// module exposes these without running the guard (its execution is behind
// `main()`, called only when the file is run directly). Each case pins a rule a
// #1799 review round found. Run via `npm run test:scripts`.
//
// The `tsc`-script and tsconfig-graph parsers this guard used to own moved to
// `lib/tsc-program.mjs` when `verify:dep-lockstep` began measuring the same
// programs (#1965); their cases moved to `lib/tsc-program.test.mjs` with them.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
matchesTestGlob,
isDisablingFlag,
isRequiredSource,
isTsc,
parseTsconfigReferences,
projectConfigFile,
refToProject,
integrityAdvice,
isScriptsTestFile,
testScriptGlobs,
testScriptNarrowingFlags,
testScriptProblems,
tscBuildStatus,
typecheckProjects,
} from "./verify-typecheck-coverage.mjs";
test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => {
@ -29,83 +27,6 @@ test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => {
assert.ok(!isRequiredSource(f), f);
});
test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => {
for (const t of [
"tsc",
"node_modules/.bin/tsc",
"./node_modules/.bin/tsc.cmd",
])
assert.ok(isTsc(t), t);
for (const t of ["vitest", "prettier", "tscx", "atsc"])
assert.ok(!isTsc(t), t);
});
test("isDisablingFlag: case-insensitive (r18)", () => {
for (const t of [
"--noCheck",
"--nocheck",
"--listFilesOnly",
"--LISTFILESONLY",
])
assert.ok(isDisablingFlag(t), t);
for (const t of ["--noEmit", "-p", "--project", "noCheck"])
assert.ok(!isDisablingFlag(t), t);
});
test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => {
const { projects, neutered } = typecheckProjects({
typecheck:
"tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
});
assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]);
assert.equal(neutered.length, 0);
// A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json.
assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [
"tsconfig.json",
]);
// Path-invoked binary still counts (r18).
assert.deepEqual(
typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" })
.projects,
["tsconfig.json"],
);
// A quoted project path (r17).
assert.deepEqual(
typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects,
["tsconfig.test.json"],
);
// `--project` long form, and `-b`/`--build` project paths (r13).
const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects;
assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [
"tsconfig.json",
]);
assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]);
assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]);
assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback
});
test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => {
const { projects, neutered } = typecheckProjects({
typecheck:
"tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json",
});
assert.deepEqual(projects, ["tsconfig.test.json"]);
assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]);
});
test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => {
const { projects } = typecheckProjects({
typecheck: "npm run typecheck:src && npm run typecheck:test",
"typecheck:src": "tsc --noEmit -p tsconfig.json",
"typecheck:test": "tsc --noEmit --project tsconfig.test.json",
});
assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]);
});
test("tscBuildStatus: ok / neutered / none (r25)", () => {
const status = (build) =>
tscBuildStatus({ validate: "npm run build", build });
@ -116,22 +37,6 @@ test("tscBuildStatus: ok / neutered / none (r25)", () => {
assert.equal(status("tsc --noEmit -p tsconfig.json"), "none"); // -b required
});
test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => {
const refs = (raw) => parseTsconfigReferences(raw);
assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]);
assert.deepEqual(
refs('/* solution */\n{ "references": [{ "path": "./a" }] }'),
["./a"],
);
assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [
"./a",
]);
assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma
assert.deepEqual(refs('{ "files": [] }'), []); // no references
assert.deepEqual(refs("{ not json"), []); // malformed
assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path
});
test("matchesTestGlob: the guard's contract, not node's glob engine", () => {
// Only the two properties the guard actually relies on — the rest of node's
// glob semantics are node's to test, which is the point of delegating to it.
@ -386,42 +291,3 @@ test("integrityAdvice: typecheck footer only for typecheck issues (r35 finding 2
/Restore the `typecheck` wiring/,
);
});
test("projectConfigFile: directory-form entry means <dir>/tsconfig.json (r26)", () => {
assert.equal(
projectConfigFile("clients/cli", "tsconfig.test.json"),
"clients/cli/tsconfig.test.json",
);
assert.equal(
projectConfigFile("clients/cli", "packages/a"),
"clients/cli/packages/a/tsconfig.json",
);
assert.equal(
projectConfigFile("clients/cli", "."),
"clients/cli/tsconfig.json",
);
});
test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => {
// A ref is relative to the tsconfig that declares it, not to clientDir.
assert.equal(
refToProject(
"clients/web",
"clients/web/tsconfig.json",
"./tsconfig.app.json",
),
"tsconfig.app.json",
);
assert.equal(
refToProject(
"clients/web",
"clients/web/sub/tsconfig.json",
"../other.json",
),
"other.json",
);
assert.equal(
refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"),
"sub/deep",
);
});