chore(deps): align zod across installs and guard against version skew - #1962
Conversation
Closes #1896 `clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The cause is not a zod regression nor a pathological inference site of ours: it is two copies of zod at *mismatched versions* inside one tsc program. `clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files resolve zod (and the SDK's zod) from the **root** install, alongside web's own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs 4.4.3 TypeScript must relate two structurally-distinct declarations of every `@modelcontextprotocol/*` schema type, which is exponential over a recursive generic surface — `TS2589 Type instantiation is excessively deep`, then OOM. Bumping root zod to 4.4.3 and changing nothing else returns the build to baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at 4.3.6. Two copies at the same version are harmless; skew is what explodes. So drop the `~4.3.6` hold on `clients/web` and move all four manifests and lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate` to keep them there. The guard derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and fails deny-by-default on any skew not in the annotated `TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so dropping it from `validate` is caught by `verify:format-coverage`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
There was a problem hiding this comment.
Pull request overview
Aligns Zod versions across installs and adds a validation guard to prevent dependency skew from causing TypeScript failures.
Changes:
- Upgrades Zod to 4.4.3 across applicable installs.
- Adds and tests
verify:dep-lockstep. - Documents the dependency lockstep requirement.
Reviewed changes
Copilot reviewed 10 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/verify-format-coverage.mjs |
Verifies the new guard remains wired. |
scripts/verify-dep-lockstep.mjs |
Implements dependency-skew detection. |
scripts/verify-dep-lockstep.test.mjs |
Tests lockstep helper behavior. |
package.json |
Adds the guard and upgrades Zod. |
package-lock.json |
Locks root Zod 4.4.3. |
clients/web/package.json |
Removes the Zod version hold. |
clients/web/package-lock.json |
Locks web Zod 4.4.3. |
clients/cli/package.json |
Aligns CLI Zod range. |
clients/cli/package-lock.json |
Updates CLI root dependency metadata. |
clients/tui/package.json |
Aligns TUI Zod range. |
clients/tui/package-lock.json |
Updates TUI root dependency metadata. |
README.md |
Documents the new validation command. |
AGENTS.md |
Adds dependency lockstep guidance. |
.github/copilot-instructions.md |
Mirrors the review guidance. |
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
…E validate row Both from Copilot's review of #1962. The candidate-set enumeration matched only `.ts`/`.tsx`, while `verify:format-coverage` and `verify:typecheck-coverage` both gate all four TypeScript extensions. No `.mts`/`.cts` exists under `core/` or `test-servers/src` today, which is exactly why the omission would have gone unnoticed until a shared dependency arrived through one and skewed — the failure this guard exists to prevent. Extract the rule as the exported `isSharedSourceFile` predicate, cover all four extensions, and pin it with regression tests (including path-boundary anchoring, so a `core-internal/` sibling isn't swept in). The README's `validate` row also omitted `test:scripts` and claimed typecheck was cli/tui only; launcher runs one too, and web typechecks via `tsc -b` inside its `build`. Restate the row to match the actual script chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 14 changed files in this pull request and generated no new comments.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Suppressed comments (1)
scripts/verify-dep-lockstep.mjs:110
- The scanner under-approximates valid TypeScript imports. In particular, a shared
.ctsfile can useimport pkg = require("pkg"), and dynamic imports may use a static template literal or a secondoptionsargument; none match these patterns, so that package never enterscandidatesand version skew passes the new guard. Please parse module specifiers with the TypeScript AST (which also avoids matching comments/strings), or cover all supported forms and add regression cases.
const SPECIFIER_FORMS = [
/\bfrom\s*["']([^"']+)["']/g,
/\bimport\s+["']([^"']+)["']/g,
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
];
…d-source scan From the suppressed comment on Copilot's second pass of #1962. Under- approximating is the dangerous direction here: a package the scan misses never enters the candidate set, so its skew passes the guard silently. Three forms went unmatched. `import x = require("pkg")` and a bare `require("pkg")` are the ordinary import syntax in `.cts` — which the previous commit just admitted to the scan, so leaving them out would have been an inconsistency introduced by that widening. A dynamic import carrying import attributes (`import("pkg", { with: … })`) was missed because the pattern required the closing paren. A static template literal (`import(\`pkg\`)`) was missed for want of a backtick in the quote class. Backticks are accepted only in the *call* forms. A `from` clause requires a string literal, so allowing them there would buy nothing while reopening the prose hazard the anchored specifier pattern closes: inline code in comments is written with backticks throughout this codebase, and admitting it inflated the derived set from 18 names to 41 with prose fragments (`tools`, `connect()`, `messages`). Inert today, since a name absent from every lockfile contributes nothing — but a prose word colliding with a real package name would silently widen the set. Pinned with a regression test. The real set is unchanged at 18, so these forms add no package today; they close the syntax against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Addressed the suppressed comment from the second review as well — it was a real gap, and one the previous commit made worse: admitting 47e0ee0 adds three forms: One deliberate limit, worth recording because it cuts against the suggestion: backticks are accepted only in the call forms. A I did not move to the TypeScript AST. The guard is a root The real candidate set is unchanged at 18 — these forms add no package today, they close the syntax against the one that arrives later. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 14 changed files in this pull request and generated no new comments.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Suppressed comments (4)
scripts/verify-dep-lockstep.mjs:46
- This candidate boundary omits
vitest.shared.mts, even though every client config imports that root-owned TypeScript file andverify:typecheck-coverageexplicitly treats it as shared non-client source. It only imports Node built-ins today, but adding a third-party import there would resolve from the root and never enter this guard’s candidate set, so cross-install skew could pass silently. Derive the scanned files from the non-client sources that participate in client typecheck projects, or at least include this shared file and cover it with a regression test.
const SHARED_SOURCE_DIRS = ["core", "test-servers/src"];
scripts/verify-dep-lockstep.mjs:52
- A name-only allowlist disables the guard for these packages regardless of the versions involved. For example, a future Hono or React major-version skew will still be classified as tolerated, even though the rationale only establishes that current patch-level differences are benign. Store an allowed major/range or predicate per package and make
partitionSkewfail when any holder falls outside it; add an out-of-range regression case.
// Packages whose cross-install skew is verified benign, each with the reason.
// This is an allowlist of *names*, not of version pairs, so an ordinary patch
// float within one of these does not churn the file — while any package NOT
// listed here failing the check is a genuine, unreviewed new skew.
//
scripts/verify-format-coverage.mjs:171
- This description overstates the vouching graph:
verify:typecheck-coverageandverify:dep-lockstepeach check onlyverify:format-coverage; they do not each assert every other guard. The graph still catches removal of one or two guards, but the comment should describe how it actually works.
// Vouch for the sibling guards: a guard can't detect being unrun itself, but the
// three can each assert the others are still wired into `validate`, so dropping
// any one is caught here. Only deleting all of them slips through.
AGENTS.md:456
- This rule says to bump a shared dependency in root plus all four clients, but this PR itself does not add zod to launcher, and
findSkewintentionally ignores installs where the package is absent. Requiring every install would add unused dependencies; clarify that every install which declares/resolves the dependency must be aligned, and mirror that wording in.github/copilot-instructions.md.
- **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), reads the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`.
… fix the vouch comment All four suppressed comments from Copilot's third pass of #1962. Each was correct. The candidate boundary omitted `vitest.shared.mts` — root-owned, imported by every client's vitest config, and already treated as shared non-client source by `verify:typecheck-coverage`. It imports only Node built-ins today, which is exactly why the omission would have gone unnoticed until a third-party import appeared there, resolved from the root, and skewed. Shared *files* are now a first-class list beside the shared dirs. The allowlist was a blanket exemption by name. Each entry's rationale establishes that a patch/minor difference is benign, which is no evidence that a React 18-vs-19 or Hono 4-vs-5 split would be — that is a different type surface. Being listed now tolerates skew only *within a major version*; a cross-major skew fails like anything else. Deriving the major from the lockfile version keeps this automatic, with no per-package range to maintain and bump. A version that can't be parsed is treated as "cannot prove same major" and fails rather than passes. `verify-format-coverage.mjs`'s comment overstated the vouching graph: the three guards do not each check the others. This one checks both siblings; each sibling checks only this one. Described as it actually works. The AGENTS.md rule said to bump a shared dependency in "root + all four clients", but launcher declares no zod and `findSkew` ignores installs where a package is absent — so the rule as written asked for spurious dependencies. Reworded to every install that *declares* it, and mirrored in `.github/copilot-instructions.md`. The failure output follows: it no longer advises adding a package that is already allowlisted, saying "MAJOR skew" instead, and it names all three shared sources rather than the two dirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Third pass generated no new comments, but it suppressed four — all correct, all fixed in 35317fd. 1. 2. The allowlist was a blanket exemption by name. Also right — each rationale establishes that a patch/minor difference is benign, which is no evidence about a React 18-vs-19 or Hono 4-vs-5 split, where the type surface itself changes. I didn't take the suggested per-package range or predicate, though: that stores a value someone has to remember to bump on every major, and a stale range fails open. Instead being listed now tolerates skew only within a major version, with the major derived from the lockfile version. Same protection, nothing to maintain. An unparseable version is treated as "cannot prove same major" and fails rather than passes. Verified live — with That message is part of the fix — the old text told you to add a package that was already listed. 3. The vouching-graph comment overstated it. Correct, and my wording was simply wrong. 4. The AGENTS.md rule asked for spurious dependencies. Correct — launcher declares no zod, and
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 14 changed files in this pull request and generated no new comments.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Suppressed comments (2)
README.md:273
- This command description omits
vitest.shared.mts, although the guard explicitly includes that root-owned shared file in its candidate scan (scripts/verify-dep-lockstep.mjs:49-55) and AGENTS.md documents it. Include it here so the public command documentation matches the implemented scope.
| `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 `core/` and `test-servers/src` — which resolve 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 `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. Runs in `validate`. |
scripts/verify-dep-lockstep.mjs:138
- These regexes miss valid TypeScript imports when comments appear between tokens, for example
import(/* webpackIgnore: true */ "pkg")orimport { x } from /* explanation */ "pkg". In those cases the dependency never enterscandidates, so version skew passes silently despite the stated over-approximation guarantee. Please collect module specifiers with the TypeScript parser (or otherwise handle trivia robustly) and add regression cases for commented static, dynamic, andrequireimports.
const SPECIFIER_FORMS = [
/\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x"
/\bimport\s+["']([^"']+)["']/g, // side-effect import "x"
/\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts])
/\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x")
];
…ts in the README Both suppressed comments from Copilot's fourth pass of #1962. TypeScript allows a comment anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` and `from /* why */ "pkg"` are valid imports the patterns stepped over. That is the dangerous direction: a missed specifier never enters the candidate set, so the package's skew passes the guard silently, against the over-approximation the module claims. A `TRIVIA` fragment now stands in for `\s*` at every inter-token position, with regression cases for the commented static, dynamic, side-effect, and `require` forms. I did not move to the TypeScript parser. This is a root `scripts/` `.mjs` tool with no bundler and no TS dependency; pulling in a compiler API to read import specifiers is a lot of machinery for a scan that only needs to over-approximate, and each missed form is a one-line pattern with a test beside it. The derived set is unchanged at 18 real names, so nothing here widens what is checked today. The README's command row also still described the candidate scan as `core/` and `test-servers/src` only, omitting the `vitest.shared.mts` the guard now includes; it also never mentioned that an allowlisted package is tolerated only within a major. Both stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Fourth pass: no new comments, two suppressed — both fixed in 2dcf145. Comment trivia between tokens. Correct, and it's the direction that actually matters: a missed specifier never enters the candidate set, so that package's skew passes silently, which contradicts the over-approximation the module claims. TypeScript allows a comment anywhere whitespace is legal, so a import { a } from /* explanation */ "express";
const b = await import(/* webpackIgnore: true */ "undici");
const c = require(/* lazy */ "yaml");
import /* side effect */ "pino";On the TypeScript-parser suggestion — raised on the second pass too — I'm declining it deliberately, so it's worth stating the reasoning once rather than leaving it implicit. This is a root README omitted
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 14 changed files in this pull request and generated 1 comment.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
From Copilot's fifth pass of #1962. `TRIVIA` covered block comments but not `//`, despite the contract right above it saying a comment is legal wherever whitespace is — so `import(// lazy\n"pkg")`, `require(// lazy\n"pkg")`, and `from // reason\n"pkg"` were stepped over, and such a package would never enter the candidate set. Same silent-miss direction as the block-comment gap. Both comment syntaxes are now covered. The line-comment branch runs to end-of-line only; the newline itself is matched by TRIVIA's `\s` branch. Regression test added alongside the block-comment one. The derived set is unchanged at 18 real names — widening trivia does not admit prose, since a specifier must still follow immediately and `packageNameOf` rejects anything not shaped like one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 14 changed files in this pull request and generated 1 comment.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Suppressed comments (1)
scripts/verify-dep-lockstep.mjs:145
- This regex also matches prose in comments and strings. For example,
// adapted from "react"addsreactto the candidate set, because the anchored package-name check only validates the captured text, not thatfromis an import token. If that installed package is skewed, an unrelated comment makesvalidatefail. The existing prose test avoids this only becausecwd omittedis not a valid package name. Parse module specifiers with the TypeScript scanner/AST (or otherwise exclude non-code tokens) and add a regression case using a real package name.
const SPECIFIER_FORMS = [
// import … from "x" / export … from "x"
new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"),
// side-effect import "x"
new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"),
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 15 changed files in this pull request and generated 1 comment.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
From Copilot's eleventh pass of #1962, and the most consequential kind of bug for a gate: it failed *open*. `topLevelLockVersions` returns an empty map for anything without a v2+ `packages` table — a lockfileVersion 1 file, or a malformed one. `main()` fed that straight into the comparison, so such an install contributed no holders at all, and a genuine skew among the remaining installs was reported as aligned. The guard would have said OK to exactly what it exists to catch. `hasReadableLockShape` now gates every discovered lockfile before any comparison, requiring a `packages` object carrying the `""` root entry npm always writes — "has a packages key" is not enough. An unreadable one is named and exits 1, telling you to regenerate it. A JSON parse failure likewise reports the file rather than dying on a bare SyntaxError with no path in it. The empty-map behavior of the pure helper is kept and its test now says why it is safe: the shape check rejects those inputs before they can reach `findSkew`. Three regression cases: a v1 lockfile in a fixture that IS skewed (so failing open would show as a pass), a `packages` table with no root entry, and the unit table for the predicate. Fixtures now write the `""` root entry, matching real npm output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Eleventh pass, one comment — and it's the most consequential bug found in this review, because it made the gate fail open. Fixed in af63a07.
The pure helper keeps returning an empty map, which is still correct — its test now records why that's safe: the shape check rejects those inputs before they can reach The executable-path regression deliberately uses a fixture that is skewed, so failing open shows up as a pass rather than as a silent no-op:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 15 changed files in this pull request and generated no new comments.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Suppressed comments (1)
scripts/verify-dep-lockstep.mjs:188
preProcessFilereports triple-slash type dependencies separately intypeReferenceDirectives, but this scan only readsimportedFiles. A shared source containing/// <reference types="foo" />can therefore load root@types/foodeclarations alongside a client's copy without that package ever entering the candidate set, so its skew passes the guard. Include type-reference directives (normalized to their actual@types/*package names) and add a regression case.
const { importedFiles } = typescript().preProcessFile(source, true, true);
From Copilot's twelfth pass of #1962. A `/// <reference types="x" />` pulls in a package's declarations exactly like an import does, but TypeScript reports it in `preProcessFile`'s `typeReferenceDirectives`, not `importedFiles` — and only the latter was read. A shared source using one could therefore load the root's copy of a package alongside a client's without it ever entering the candidate set, so its skew would pass. Same silent-miss direction as the earlier syntax gaps, through a channel the parser switch didn't cover because I only read one of its two outputs. `typeReferencePackageNames` maps a directive to both candidates, since the name is the *type* name and not the package: `node` resolves to `@types/node`, while a package shipping its own declarations resolves to itself. Returning both over-approximates, the safe direction — whichever isn't installed drops out downstream. Scoped names mangle `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. `path` references name a file, not a package, and are ignored. The real candidate set is unchanged at 17: no shared source uses a triple-slash reference today. As with the other syntax fixes, this closes the channel against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Twelfth pass: no new comments, one suppressed — correct, fixed in 87c1e05. A
/// <reference types="node" /> → node, @types/node
/// <reference types="express" /> → express, @types/express
/// <reference path="./local.d.ts" /> → (ignored)The real candidate set is unchanged at 17 — no shared source uses a triple-slash reference today, so like the other syntax fixes this closes the channel against the one that arrives later rather than changing what's checked now.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 15 changed files in this pull request and generated no new comments.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Suppressed comments (1)
scripts/verify-format-coverage.mjs:177
- The newly added
verify:dep-lockstepsibling-vouch branch has no regression test: the script tests only exerciserootReachesScriptitself and the opposite direction (verify-dep-lockstepchecking for format coverage). A typo or removal here could therefore leavetest:scriptsgreen while the new guard silently stops being enforced. Add a fixture/subprocess test that removesverify:dep-lockstepfromvalidateand assertsverify-format-coverageexits nonzero, as required for modified root tooling.
for (const sibling of ["verify:typecheck-coverage", "verify:dep-lockstep"]) {
From Copilot's thirteenth pass of #1962. This PR added `verify:dep-lockstep` to the vouch loop in `verify-format-coverage.mjs` without a test for it. The existing tests exercise `rootReachesScript` in isolation and the opposite direction (dep-lockstep vouching for format-coverage), so a typo in a sibling's name here would have left `test:scripts` green while that guard silently stopped being enforced — the same "a gate that stops gating" failure the vouch cycle exists to prevent, one level up. Three cases, reusing the fixture-repo approach: dropping either sibling from `validate` exits 1 naming that sibling, and with both wired no sibling is reported missing. The last asserts on the *reason* rather than the exit status, since the fixture has no client manifests to harvest globs from and so fails later for an unrelated reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Thirteenth pass: no new comments, one suppressed — correct, fixed in the commit above. This PR added
The third asserts on the reason rather than the exit status: the fixture has no client manifests to harvest globs from, so the run fails later for an unrelated cause, and asserting exit 0 there would have been a false pass.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 16 changed files in this pull request and generated 1 comment.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
…s key From Copilot's fourteenth pass of #1962. `hasReadableLockShape` and the diagnostic it drives both promise "lockfileVersion 2+", but the check only looked for a `packages` table with a root entry. A file declaring v1 while carrying such a table was accepted, produced an empty version map, and could make a real skew read as aligned once only one other holder remained — the same fail-open the shape check was added to close, through the half of the contract that wasn't enforced. The declared version is now verified: a finite number, 2 or greater. Absent, non-numeric, and declared-v1-with-packages cases are all in the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
|
Fourteenth pass, one comment — correct, fixed in the commit above.
The declared version is now verified as a finite number ≥ 2. The table covers absent, non-numeric (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 16 changed files in this pull request and generated 1 comment.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
…ckfile From Copilot's fifteenth pass of #1962. `installDirs` filtered on the presence of a lockfile, so a missing one silently removed that install from the comparison rather than failing. For the root — the install every shared source resolves from — that meant the guard could report success from client locks alone, which is the deny-by-default contract inverted. Enrolment is now by `package.json` (an install we are meant to compare), with the root always enrolled, and a missing lockfile is a named, loud failure. A `clients/` directory with no `package.json` is not an install and is skipped, so a stray scratch dir isn't asked for a lockfile it should never have. Three regression cases: a missing client lockfile in a fixture that IS skewed (so failing open would show as a pass), a missing ROOT lockfile, and a stray `clients/` dir that must not be enrolled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (3)
- clients/cli/package-lock.json: Generated file
- clients/tui/package-lock.json: Generated file
- clients/web/package-lock.json: Generated file
Closes #1896
Diagnosis
It is neither a zod regression nor a pathological inference site of ours. It is two copies of zod at mismatched versions inside one
tscprogram.clients/web/tsconfig.test.jsoncompilestest-servers/srcalongside web's own sources. Those test-server files resolvezod— and the SDK'szod— from the root install, while web's sources resolve fromclients/web/node_modules. Confirmed directly:At matching versions the duplication is harmless. Skewed, TypeScript must structurally relate two distinct declarations of every
@modelcontextprotocol/*schema type — exponential over a recursive-generic surface. The project reports it plainly before dying:Proof
Bumping root zod to 4.4.3 and changing nothing else returns
clients/web'stsc -b --forceto baseline:So the constraint was never "4.4.x is too expensive" — it was the skew. cli and tui typecheck clean under the same skew, which is why the issue only ever manifested in web.
The fix
~4.3.6hold onclients/web; all four manifests declare^4.4.3and all four lockfiles resolve4.4.3. The three declared ranges agree again.npm run verify:dep-lockstep(scripts/verify-dep-lockstep.mjs) tovalidate. It derives its candidate set from the packagescore/andtest-servers/srcimport — the two first-party surfaces compiled into more than one client's program — so a new shared dependency is covered without editing the guard. It reads the committed lockfiles' top-levelnode_modules/<pkg>entries (a nested transitive duplicate inside one install is routine and deliberately ignored) and fails deny-by-default on any candidate held at two versions.TOLERATED_SKEW, an allowlist of names rather than version pairs — so an ordinary patch float doesn't churn it, while an unlisted package that starts skewing still fails.react,hono,jose, and@modelcontextprotocol/ext-appsare listed today, each with why its types can't blow up.validateis caught byverify:format-coverage.Raising the heap with
--max-old-space-sizewas explicitly avoided — it would have hidden the class rather than fixed it. That reasoning is now recorded inAGENTS.md.Verification
Negative test — reintroducing exactly the issue's skew:
Restored:
14 table-driven unit tests in
scripts/verify-dep-lockstep.test.mjs(test:scriptsnow 49 tests, green).npm run ci: all steps pass —validate(incl. all three coverage guards),coveragefor cli/tui/launcher,verify:build-gate,smoke, and Storybook. Web'stest:coverageruns 4881/4881 tests green but exits 1 on two pre-existing unhandled rejections ininspectorClient.test.ts— unrelated to this change (that file is byte-identical tov2/mainhere) and already owned by open PR #1958.No UI surface, so no screenshots.
Docs
AGENTS.mdgains the "one version per install-crossing dependency" rule under the pre-push gate;README.mdgains the script-table row and tree entry; the.github/copilot-instructions.mdmirror gains the reviewer-facing rule that a dependency bump must land in every install.🤖 Generated with Claude Code
https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3