Two findings from the Copilot review of #1965, both real. 1. The candidates were flattened to package names before the version comparison, so a third install holding a different copy failed the guard even when no program loads that copy beside another — and the diagnostic named an install that never took part. `crossInstallPackages` now returns the co-occurrences themselves (name → program → install → entry paths) and `findSkew` compares per program, across only the installs that met there. 2. Folding a nested copy onto its outermost install lost the path that identifies which copy was loaded, and the version was then read from the install's top-level entry — absent or differently versioned, either way letting a real pair pass. `classifyModulePath` now also returns the `entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes `lockVersionsByPath`, and each holder is priced from the entry the program resolved. A resolved copy with no lockfile entry is now a hard failure rather than a silently dropped holder. The failure output prints `in <program>` with each holder's install path beside its version, so a nested copy is distinguishable from a top-level one. Signed-off-by: cliffhall <cliff@futurescale.com>
292 lines
9.8 KiB
JavaScript
292 lines
9.8 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",
|
|
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"],
|
|
);
|
|
});
|