chore: widen verify:dep-lockstep to declared cross-install copies (#2226)

`clients/cli` resolved `@types/node` 24.13.1 against the root's 24.13.3.
Neither client declares it — #2196 consolidated it to the root — so the cli
copy arrived transitively via `@types/express` and was constrained by no
range of ours. `verify:dep-lockstep` reported OK and was right to: it
compares only packages that reach one `tsc` **program** from two installs,
and no one program sees both copies.

Fixes the instance and the class, which the issue notes are not exclusive.

**The class.** The guard grows a second tier alongside the program-derived
one, asking the weaker but broader question `AGENTS.md`'s "one version per
install-crossing dependency" rule actually states: does a package this repo
declares anywhere resolve to two versions across our installs at all? The
candidate set is every name in any install's `dependencies`/`devDependencies`
that more than one install holds a **top-level** copy of — 17 packages today.
Nested copies are excluded: one exists because some dependency asked for a
different version, so it is that dependency's range, not ours.

Neither tier subsumes the other. The program tier sees a copy no manifest
names (the `@modelcontextprotocol/sdk` case, arriving through another
package's `.d.ts`). The declared tier sees a transitive copy no program
loads (this issue), two *clients* disagreeing with no root copy involved
(`@types/react`, web against tui), and the peer shadows `AGENTS.md` calls out
as ungated — `eslint`, `typescript`, `vitest`.

Deny-by-default, with its own empty `TOLERATED_DECLARED_SKEW` and the same
within-a-major rule, now stated once in a shared `toleratesSkew` so the two
tiers cannot drift.

**The instance, and three others the new tier surfaced.** All four were
patch-level:

- `@types/node` — cli 24.13.1 vs root 24.13.3. Transitive, so it takes an
  `overrides` entry in `clients/cli` (the repo's sanctioned lever for a
  transitive pin), at the root's own `^24.12.4` rather than a frozen number.
- `@types/react` — web 19.2.18 vs tui 19.2.17.
- `react` — web 19.2.8 vs root/tui 19.2.7.
- `@vitejs/plugin-react` — web 6.0.5 vs root 6.0.2.

The last three are `npm update` in the stale install: it moves the lockfile
within the declared range without widening a range, which matters for the
root `react ^19.0.0` that `ink`'s externalized-bundle exemption depends on.
No declaration or range changed.

Tests: 4 end-to-end cases (the fixture gains manifest deps and a client-side
`solo` install, so the skew is real and no program holds both copies) and 13
helper cases.

Closes #2226

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-05 14:10:36 -04:00
commit 1e6bf6425a

View file

@ -82,6 +82,9 @@ function makeFixture({
webTsconfig,
rootNestedInner,
cliDeps,
rootManifestDeps,
webManifestDeps,
webSolo,
} = {}) {
// realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to
// `/private/var/...`. The guard only runs `main()` when `import.meta.url`
@ -101,6 +104,7 @@ function makeFixture({
write("package.json", {
name: "fixture",
...(rootManifestDeps ? { dependencies: rootManifestDeps } : {}),
scripts: scripts ?? {
validate: "npm run verify:format-coverage && npm run verify:dep-lockstep",
"verify:format-coverage": "node scripts/verify-format-coverage.mjs",
@ -121,6 +125,7 @@ function makeFixture({
);
write("clients/web/package.json", {
name: "web",
...(webManifestDeps ? { devDependencies: webManifestDeps } : {}),
scripts: webScripts ?? { typecheck: "tsc --noEmit -p tsconfig.json" },
});
write(
@ -166,6 +171,20 @@ function makeFixture({
stub(installRoot, versions, "inner", "export type Inner = number;\n");
}
stub("", root, "solo", "export type Solo = string;\n");
// `solo` under the CLIENT install as well. Nothing in `clients/web/src`
// imports it, so the program still resolves exactly one copy (the root's, via
// the shared `core/`) — the declared tier's whole point is that it sees this
// second copy anyway (#2226).
const webLockDeps = { ...web };
if (webSolo) {
stub(
"clients/web/",
{ solo: webSolo },
"solo",
"export type Solo = string;\n",
);
webLockDeps.solo = webSolo;
}
// The root reaches `inner` only through a copy NESTED under `outer`, at a
// version its top-level entry does not carry. npm's own conflict resolution,
@ -182,7 +201,7 @@ function makeFixture({
}
write("package-lock.json", lock(rootLockDeps));
write("clients/web/package-lock.json", rawWebLock ?? lock(web));
write("clients/web/package-lock.json", rawWebLock ?? lock(webLockDeps));
// A second client, to prove a third install's copy is not dragged into a
// comparison it never took part in. Its program spans only its own install.
@ -503,3 +522,75 @@ test("main: exits 1 when the root validate no longer runs the sibling guard", ()
},
);
});
// ---------------------------------------------------------------------------
// The declared tier (#2226) — skew across installs that no ONE program sees.
// ---------------------------------------------------------------------------
test("main: a DECLARED package skewed across installs fails even when no program holds both copies (#2226)", () => {
// The exact shape the issue reported. `solo` is installed under the root and
// under `clients/web`, and both manifests name it — but only the shared
// `core/` imports it, so the client's program resolves the root's copy alone
// and the program tier is silent, correctly. The declared tier is what sees
// it, which is the whole reason it exists: cli's `@types/node` was hoisted in
// via `@types/express` and no program ever met the root's copy.
withFixture(
{
rootManifestDeps: { solo: "^7.0.0" },
webManifestDeps: { solo: "^7.0.0" },
webSolo: "7.0.0",
},
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 1, out);
assert.match(out, /1 declared dependency resolves/);
assert.match(out, /7\.8\.9\s+\(\.\/node_modules\/solo\)/);
assert.match(out, /7\.0\.0\s+\(clients\/web\/node_modules\/solo\)/);
// The program tier must NOT have claimed it — its message names a program.
assert.doesNotMatch(out, /in clients\/web\/tsconfig\.json/);
},
);
});
test("main: a declared package held at the same version in both installs passes and is counted", () => {
withFixture(
{
rootManifestDeps: { solo: "^7.0.0" },
webManifestDeps: { solo: "^7.0.0" },
webSolo: "7.8.9",
},
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 0, out);
assert.match(out, /1 declared dependencies agree/);
},
);
});
test("main: an installed package NO manifest declares is not a declared-tier candidate", () => {
// `outer` and `inner` sit in both installs in every fixture, but no manifest
// names them. The declared tier's boundary is what the repo declares, so its
// candidate count is zero here — the program tier is what covers those.
withFixture({}, (dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 0, out);
assert.match(out, /0 declared dependencies agree/);
});
});
test("main: a declared package only ONE install holds cannot skew", () => {
// `solo` is declared at the root and listed in the root lockfile only. One
// holder is not an install-crossing dependency, so its version is nobody
// else's business.
withFixture(
{
rootManifestDeps: { solo: "^7.0.0" },
webDeps: { outer: "1.2.3", inner: "4.5.6" },
},
(dir) => {
const { status, out } = runGuard(dir);
assert.equal(status, 0, out);
assert.match(out, /0 declared dependencies agree/);
},
);
});