`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>
259 lines
8.7 KiB
JavaScript
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",
|
|
]);
|
|
});
|