inspector/scripts/lib/tsc-program.test.mjs
cliffhall 7f9be24bf3 chore(scripts): derive dep-lockstep candidates from what enters each tsc program
`verify:dep-lockstep` derived its candidate set from the packages the shared
first-party sources named *directly*, so a package whose declarations reach a
program only through another package's `.d.ts` was invisible to it — it could
skew across installs and recreate the two-copy blow-up #1896 was about while
the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never
written in first-party code, yet 16 of its `.d.ts` files land in clients/web's
test program.

Replace the derivation with the measured one: list every client tsconfig
project with `tsc --listFilesOnly`, map each resolved `node_modules` file to
its owning install and package, and keep the packages that reach ONE program
from TWO installs. That is exactly the set that can put two structurally
distinct copies of a type in front of one checker. The rest of the guard is
unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as
a name allowlist scoped to within-a-major.

The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with
`verify:typecheck-coverage`, so the two guards can't disagree about what a
program contains. It is deliberately not cached to disk between the two
processes: a stale fingerprint would make a guard measure a program that no
longer exists and pass on a real miss. The second pass costs ~14s.

`TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are
root-only since #1970 and cannot skew; `react` (whose types are `@types/react`,
resolved from one install) and `hono` never reach one program from two
installs. Each would be a rationale for a skew that cannot occur.

Closes #1965

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 12:17:15 -04:00

259 lines
8.7 KiB
JavaScript

// 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",
});
assert.deepEqual(
classifyModulePath("clients/web/node_modules/zod/index.d.ts"),
{ installRoot: "clients/web", name: "zod" },
);
// A scoped package keeps both segments.
assert.deepEqual(
classifyModulePath("node_modules/@modelcontextprotocol/sdk/dist/x.d.ts"),
{ installRoot: ".", name: "@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, and the
// lockfile comparison ignores it for the same reason.
assert.deepEqual(
classifyModulePath("node_modules/a/node_modules/zod/index.d.ts"),
{ installRoot: ".", name: "zod" },
);
assert.deepEqual(
classifyModulePath(
"clients/cli/node_modules/a/node_modules/@scope/b/index.d.ts",
),
{ installRoot: "clients/cli", name: "@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"]);
assert.deepEqual([...found.get("zod").installRoots].sort(), [
".",
"clients/web",
]);
assert.deepEqual(
[...found.get("zod").programs],
["clients/web/tsconfig.test.json"],
);
});
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").programs].sort(), [
"web/app",
"web/test",
]);
});