Merge origin/v2/main: move the #1939 npx fix into the shared tsc-program lib
Upstream #1965 moved the tsc --listFilesOnly machinery into scripts/lib/tsc-program.mjs (shared with the new verify:dep-lockstep), still spawning `npx` — which is ENOENT on Windows. Resolve the conflict by taking upstream's factoring and porting the #1939 fix into the lib: rawProjectListing and verify-typecheck-coverage's --showConfig pass now spawn process.execPath with the cached, hard-failing tscEntry resolver (exported from tsc-program.mjs). Fixture copies in both main tests pick up the new lib import graph. verify:dep-lockstep now runs on Windows too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
7164806be3
2 changed files with 11123 additions and 1634 deletions
425
scripts/lib/tsc-program.mjs
Normal file
425
scripts/lib/tsc-program.mjs
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
// 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";
|
||||
import { resolveNodeBin } from "./resolve-node-bin.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.
|
||||
*/
|
||||
/**
|
||||
* The tsc JS entry a client's programs are measured with, resolved from the
|
||||
* client dir up the node_modules tree exactly as `npx --no-install tsc` walked
|
||||
* — but spawnable shell-free on Windows, where `npx` is a `.cmd` shim that
|
||||
* `execFileSync` can't start (ENOENT — #1939). A resolution failure is a hard
|
||||
* "cannot measure" error rather than an empty file set: the old ENOENT was
|
||||
* doubly silent, echoing "(no diagnostic captured)" per project and then
|
||||
* reporting every tracked file in the repo as uncovered. Cached per client;
|
||||
* exported so a consumer's own tsc pass (`verify-typecheck-coverage`'s
|
||||
* `--showConfig`) spawns the same entry the listings do.
|
||||
*/
|
||||
const tscEntryCache = new Map();
|
||||
export function tscEntry(clientDir) {
|
||||
const cached = tscEntryCache.get(clientDir);
|
||||
if (cached) return cached;
|
||||
let entry;
|
||||
try {
|
||||
entry = resolveNodeBin("typescript", "tsc", path.join(repoRoot, clientDir));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`cannot resolve \`typescript\` from ${clientDir} (${err.message}): no tsc program can be measured. Run \`npm install\` at the repo root first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
tscEntryCache.set(clientDir, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
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(
|
||||
process.execPath,
|
||||
[tscEntry(clientDir), "-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;
|
||||
}
|
||||
292
scripts/lib/tsc-program.test.mjs
Normal file
292
scripts/lib/tsc-program.test.mjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue