Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,15 @@ v2/main/
│ # pack-and-verify.mjs, and lib/ shared helpers
│ # (tsc-program.mjs is the `tsc --listFilesOnly`
│ # measurement both coverage guards read a program
│ # through — #1965). Prettier-gated via
│ # through — #1965; resolve-node-bin.mjs resolves a
│ # package's bin through its own package.json, so the
│ # verify/smoke scripts spawn it with `process.execPath`
│ # rather than the `npx` `.cmd` shim Node refuses to
│ # spawn shell-free on Windows. pack-and-verify.mjs is
│ # the exception — its children are `npm` itself and the
│ # installed `.bin` shim, neither resolvable that way, so
│ # it keeps a Windows shell and quotes its generated
│ # paths via win-shell-args.mjs — #1939). Prettier-gated via
│ # `format:check:scripts`; its own pure parsers are
│ # unit-tested by `npm run test:scripts` (node --test).
├── specification/ # Build specification
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ Each client self-validates from its own folder; the root scripts chain them. The
| `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`, `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 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, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. 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 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. |
Expand Down
117 changes: 117 additions & 0 deletions scripts/lib/resolve-node-bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Shared resolver for spawning a package's CLI cross-platform (#1939).
//
// On Windows, `npx`/`npm` are `.cmd` shims, not executables — a shell-free
// `execFileSync`/`spawnSync` cannot start one (Node refuses `.cmd`/`.bat`
// spawns without `shell: true` since the CVE-2024-27980 hardening) and throws
// `ENOENT`. GitHub CI runs Linux, so the gate stayed green there while being
// unrunnable for any Windows contributor. Instead of shelling through `npx`,
// resolve the JS entry behind the package's bin and run it with
// `process.execPath`: cross-platform, shell-free (no quoting hazards), faster
// (no npx resolution), and pinned to the locally installed package exactly as
// `npx --no-install` was.

import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";

/**
* Absolute path of the JS entry behind a package's bin (e.g. typescript's
* `tsc`, vite's `vite`), resolved from `fromDir` up the node_modules tree —
* the same walk `npx --no-install` does, minus the `.cmd` shim a shell-free
* spawn can't start on Windows. Reads the package's manifest and takes the
* path out of its `bin` field (what npx itself does) rather than resolving the
* bin path directly, because an `exports` map blocks deep resolution — Vite 8
* doesn't export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")`
* throws `ERR_PACKAGE_PATH_NOT_EXPORTED`.
*
* Throws if the package isn't installed from `fromDir`, declares no such bin,
* or declares one whose file is missing — the caller decides whether that's a
* hard "cannot measure" error or a fallback.
*/
export function resolveNodeBin(pkg, binName, fromDir) {
const pkgPath = resolveManifest(pkg, fromDir);
const manifest = JSON.parse(readFileSync(pkgPath, "utf8"));
const { bin } = manifest;
// A string-form `bin` is npm's shorthand for ONE command, named after the
// package (unscoped). It does not make every requested `binName` valid, so
// match it rather than accepting whatever was asked for — otherwise a typo
// silently resolves the package's only executable instead of failing.
const rel =
typeof bin === "string"
? unscopedName(manifest.name ?? pkg) === binName
? bin
: undefined
: bin?.[binName];
if (typeof rel !== "string")
throw new Error(`${pkg} declares no "${binName}" bin in its package.json`);
const entry = path.join(path.dirname(pkgPath), rel);
// A declared bin whose file is absent is a partial install, and it fails
// *silently* downstream: `process.execPath <missing>` still spawns fine and
// exits 1 with nothing on stdout, which `rawProjectListing` records as "no
// diagnostic captured" and turns into the bogus every-file-uncovered report
// this helper exists to eliminate. Fail here, where the remedy is actionable.
if (!existsSync(entry))
throw new Error(
`${pkg}'s "${binName}" bin points at a missing file: ${entry}`,
);
return entry;
}

/**
* The package's own `package.json`, found by walking the same `node_modules`
* chain Node's resolver would from `fromDir` — deliberately NOT via
* `require.resolve("<pkg>/package.json")`.
*
* That shortcut reads as safe and isn't: subpath resolution is governed by the
* package's `exports`, and a package may declare one that omits `./package.json`
* (Node dropped its special case for it, so there is no guaranteed export). Such
* a package throws `ERR_PACKAGE_PATH_NOT_EXPORTED` here even though its bin is
* installed and perfectly spawnable — a false "not installed" that would send
* someone to `npm install` for a package already on disk. `resolve.paths()`
* gives the search dirs without consulting `exports` at all.
*
* Its list is then filtered to the `node_modules` directories on `fromDir`'s
* own ancestor chain, because `resolve.paths()` also appends Node's GLOBAL
* FOLDERS (`$HOME/.node_modules`, `$HOME/.node_libraries`, `$PREFIX/lib/node`)
* and any `NODE_PATH` entries. Accepting those would quietly break the
* guarantee this module exists to keep — that the spawned tsc/vite is the
* REPO-PINNED one, exactly as `npx --no-install` promised. A missing repo
* install would then find a globally installed TypeScript and measure the
* programs with the wrong compiler, instead of failing with the actionable
* "run npm install" this helper is supposed to produce.
*/
function resolveManifest(pkg, fromDir) {
const require = createRequire(path.join(fromDir, "package.json"));
// `resolve.paths` returns null only for builtins, which have no bin to find.
for (const dir of localSearchDirs(
require.resolve.paths(pkg) ?? [],
fromDir,
)) {
const candidate = path.join(dir, ...pkg.split("/"), "package.json");
if (existsSync(candidate)) return candidate;
}
throw new Error(`cannot find ${pkg} from ${fromDir}`);
}

/**
* The subset of `resolve.paths()` that is a `node_modules` directory sitting
* directly under `fromDir` or one of its ancestors — i.e. the local walk, with
* Node's global folders and `NODE_PATH` dropped. Exported for its own test.
*/
export function localSearchDirs(dirs, fromDir) {
const ancestors = new Set();
for (let dir = path.resolve(fromDir); ; dir = path.dirname(dir)) {
ancestors.add(dir);
if (dir === path.dirname(dir)) break;
}
return dirs.filter(
(dir) =>
path.basename(dir) === "node_modules" &&
ancestors.has(path.dirname(path.resolve(dir))),
);
}

/** `@scope/name` → `name`; an unscoped name is returned unchanged. */
function unscopedName(name) {
return name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name;
}
Loading