Skip to content

chore(deps): align zod across installs and guard against version skew - #1962

Merged
cliffhall merged 16 commits into
v2/mainfrom
v2/chore/zod-version-lockstep
Aug 11, 2026
Merged

chore(deps): align zod across installs and guard against version skew#1962
cliffhall merged 16 commits into
v2/mainfrom
v2/chore/zod-version-lockstep

Conversation

@cliffhall

Copy link
Copy Markdown
Member

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 tsc program.

clients/web/tsconfig.test.json compiles test-servers/src alongside web's own sources. Those test-server files resolve zod — and the SDK's zod — from the root install, while web's sources resolve from clients/web/node_modules. Confirmed directly:

$ tsc -p tsconfig.test.json --listFilesOnly | grep /zod/ | ...
/Users/…/clients/web/node_modules/zod     # 4.4.3
/Users/…/node_modules/zod                 # 4.3.6

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:

src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts(328,32):
  error TS2589: Type instantiation is excessively deep and possibly infinite.

Proof

Bumping root zod to 4.4.3 and changing nothing else returns clients/web's tsc -b --force to baseline:

root zod web zod Build time Instantiations Peak memory
4.3.6 4.3.6 11.20s 2,567,503 1,913,626K
4.3.6 4.4.3 OOM @ ~38s 4GB heap exhausted
4.4.3 4.4.3 11.38s 2,567,882 1,583,655K

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

  • Drop the ~4.3.6 hold on clients/web; all four manifests declare ^4.4.3 and all four lockfiles resolve 4.4.3. The three declared ranges agree again.
  • Add npm run verify:dep-lockstep (scripts/verify-dep-lockstep.mjs) to validate. It derives its candidate set from the packages core/ and test-servers/src import — 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-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.
  • The escape hatch is 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-apps are listed today, each with why its types can't blow up.
  • The guard joins the existing mutual-vouch ring, so dropping it from validate is caught by verify:format-coverage.

Raising the heap with --max-old-space-size was explicitly avoided — it would have hidden the class rather than fixed it. That reasoning is now recorded in AGENTS.md.

Verification

Negative test — reintroducing exactly the issue's skew:

$ node scripts/verify-dep-lockstep.mjs      # root lock forced back to 4.3.6
verify:dep-lockstep — 1 dependency resolves to different versions across installs:

  zod
    4.3.6  (.)
    4.4.3  (clients/cli)
    4.4.3  (clients/tui)
    4.4.3  (clients/web)
…
exit=1

Restored:

verify:dep-lockstep — OK: 18 install-crossing dependencies agree across 5 installs, 4 tolerated.

14 table-driven unit tests in scripts/verify-dep-lockstep.test.mjs (test:scripts now 49 tests, green).

npm run ci: all steps pass — validate (incl. all three coverage guards), coverage for cli/tui/launcher, verify:build-gate, smoke, and Storybook. Web's test:coverage runs 4881/4881 tests green but exits 1 on two pre-existing unhandled rejections in inspectorClient.test.ts — unrelated to this change (that file is byte-identical to v2/main here) and already owned by open PR #1958.

No UI surface, so no screenshots.

Docs

AGENTS.md gains the "one version per install-crossing dependency" rule under the pre-push gate; README.md gains the script-table row and tree entry; the .github/copilot-instructions.md mirror gains the reviewer-facing rule that a dependency bump must land in every install.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3

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
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 11, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 11, 2026 00:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/verify-dep-lockstep.mjs Outdated
Comment thread README.md Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .cts file can use import pkg = require("pkg"), and dynamic imports may use a static template literal or a second options argument; none match these patterns, so that package never enters candidates and 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
@cliffhall

Copy link
Copy Markdown
Member Author

Addressed the suppressed comment from the second review as well — it was a real gap, and one the previous commit made worse: admitting .cts to the scan without matching import x = require(…) left the ordinary import syntax of that extension unmatched. Under-approximating is the dangerous direction, since a package the scan misses never enters the candidate set and its skew passes silently.

47e0ee0 adds three forms: import x = require("pkg") / bare require("pkg"), a dynamic import carrying import attributes (the pattern had required the closing paren, so import("pkg", { with: … }) hid the specifier), and a static template literal.

One deliberate limit, worth recording because it cuts against the suggestion: backticks are accepted only in the call forms. A from clause requires a string literal, so allowing them there buys nothing — and it measurably reopens the prose hazard the anchored specifier pattern exists to close. Inline code in comments is written with backticks throughout this codebase, and admitting it inflated the derived set from 18 real names to 41, the extras being prose fragments (tools, connect(), messages, settings.headers). Those are inert today, since a name absent from every lockfile contributes nothing downstream, but a prose word colliding with a real package name would silently widen the set. Both directions are now pinned by regression tests.

I did not move to the TypeScript AST. The guard is a root scripts/ .mjs tool with no bundler and no TS dependency of its own, and reaching for a compiler API to read import specifiers is a large amount of machinery for a scan that only needs to over-approximate. The failure mode that matters is a missed specifier; the forms above close the ones that exist, and a miss is now a one-line pattern addition with a test beside it.

The real candidate set is unchanged at 18 — these forms add no package today, they close the syntax against the one that arrives later. npm run validate passes; test:scripts is at 53.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 and verify:typecheck-coverage explicitly 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 partitionSkew fail 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-coverage and verify:dep-lockstep each check only verify: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 findSkew intentionally 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
@cliffhall

Copy link
Copy Markdown
Member Author

Third pass generated no new comments, but it suppressed four — all correct, all fixed in 35317fd.

1. vitest.shared.mts was outside the candidate boundary. Right, and for the same reason the .mts/.cts gap was worth fixing: it's root-owned, imported by every client's vitest config, already treated as shared non-client source by verify:typecheck-coverage, and imports only Node built-ins today. So nothing would have surfaced the omission 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, with a regression test (including that a nested clients/web/vitest.shared.mts is not the shared one).

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 react on the allowlist and web forced to 18.3.1:

  react  (allowlisted — but this is a MAJOR skew)
    19.2.7  (.)
    19.2.7  (clients/tui)
    18.3.1  (clients/web)
…
Note: an allowlisted package is tolerated only WITHIN a major version …

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. verify:format-coverage checks both siblings; each sibling checks only verify:format-coverage. The comment now says that, including which removal is caught by whom.

4. The AGENTS.md rule asked for spurious dependencies. Correct — launcher declares no zod, and findSkew ignores installs where a package is absent, so "root + all four clients" was both wrong and harmful advice. Now "every install that declares it," with an explicit "don't add it to an install that doesn't" in both the rule and the guard's failure output, and mirrored in .github/copilot-instructions.md.

npm run validate passes; test:scripts is at 57.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") or import { x } from /* explanation */ "pkg". In those cases the dependency never enters candidates, 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, and require imports.
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
@cliffhall

Copy link
Copy Markdown
Member Author

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 TRIVIA fragment now stands in for \s* at every inter-token position. Regression cases cover the commented static, dynamic, side-effect, and require forms:

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 scripts/ .mjs tool: no bundler, no TS dependency of its own, and the root has no vitest harness by design. Pulling in a compiler API to read import specifiers is a large amount of machinery for a scan whose only requirement is to over-approximate, and it would make the guard's own correctness depend on a typescript version. The forms that exist are now covered, each miss is a one-line pattern plus a test, and the derived set is unchanged at 18 real names — so nothing here widens what's actually checked today.

README omitted vitest.shared.mts. Right — the row still described the scan as core/ and test-servers/src only, and it never mentioned the within-a-major limit on the allowlist either. Both stated now.

npm run validate passes; test:scripts is at 58.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread scripts/verify-dep-lockstep.mjs Outdated
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" adds react to the candidate set, because the anchored package-name check only validates the captured text, not that from is an import token. If that installed package is skewed, an unrelated comment makes validate fail. The existing prose test avoids this only because cwd omitted is 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"),

Comment thread scripts/verify-dep-lockstep.mjs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread scripts/verify-dep-lockstep.mjs
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
@cliffhall

Copy link
Copy Markdown
Member Author

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.

topLevelLockVersions returns an empty map for anything without a v2+ packages table, and main() fed that straight into the comparison. So a lockfileVersion 1 or malformed lockfile made that install contribute no holders at all, and a genuine skew among the remaining installs was reported as aligned. The guard would have printed OK for precisely the condition it exists to catch. Every other finding in this review was about coverage; this one was about the gate lying.

hasReadableLockShape now screens every discovered lockfile before any comparison. It requires a packages object carrying the "" root entry npm always writes — "has a packages key" isn't sufficient, and there's a case pinning that. Unreadable ones are named and exit 1 with instructions to regenerate; a JSON parse failure now reports which file rather than dying on a bare SyntaxError with no path in it.

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 findSkew. That coupling was previously implicit, which is how the hole existed.

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:

root:  zod 4.3.6   web: lockfileVersion 1 (dependencies only)
→ verify:dep-lockstep — 1 lockfile(s) are not in a readable format:
    clients/web/package-lock.json
  exit 1

npm run validate passes; test:scripts is at 69.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • preProcessFile reports triple-slash type dependencies separately in typeReferenceDirectives, but this scan only reads importedFiles. A shared source containing /// <reference types="foo" /> can therefore load root @types/foo declarations 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
@cliffhall

Copy link
Copy Markdown
Member Author

Twelfth pass: no new comments, one suppressed — correct, fixed in 87c1e05.

A /// <reference types="x" /> pulls in a package's declarations exactly like an import, but preProcessFile reports it in typeReferenceDirectives, not importedFiles — and I was only reading the latter. Same silent-miss direction as the earlier syntax gaps, arriving through a channel the parser switch didn't close because I consumed one of its two outputs.

typeReferencePackageNames maps a directive to both candidates, because the name is the type name and not the package: node@types/node, while a package shipping its own declarations resolves to itself. Returning both over-approximates, which is 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 rather than a package and are ignored.

/// <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.

npm run validate passes; test:scripts is at 71.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-lockstep sibling-vouch branch has no regression test: the script tests only exercise rootReachesScript itself and the opposite direction (verify-dep-lockstep checking for format coverage). A typo or removal here could therefore leave test:scripts green while the new guard silently stops being enforced. Add a fixture/subprocess test that removes verify:dep-lockstep from validate and asserts verify-format-coverage exits 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
@cliffhall

Copy link
Copy Markdown
Member Author

Thirteenth pass: no new comments, one suppressed — correct, fixed in the commit above.

This PR added verify:dep-lockstep to the vouch loop in verify-format-coverage.mjs and never tested it. The existing tests cover 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 cycle exists to prevent, one level up. Precisely the kind of hole the mutual-vouch design is supposed to make impossible, and I opened it.

scripts/verify-format-coverage.main.test.mjs reuses the fixture-repo approach, three cases:

  1. drop verify:dep-lockstep from validate → exit 1 naming it
  2. drop verify:typecheck-coverage → exit 1 naming it
  3. both wired → no sibling reported missing

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.

npm run validate passes; test:scripts is at 74.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread scripts/verify-dep-lockstep.mjs
…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
@cliffhall

Copy link
Copy Markdown
Member Author

Fourteenth pass, one comment — correct, fixed in the commit above.

hasReadableLockShape and the diagnostic it drives both promise "lockfileVersion 2+", but the check only looked for a packages table with a root entry. So { lockfileVersion: 1, packages: { "": {} } } was accepted, produced an empty version map, and could make a real skew read as aligned once only one other holder remained. That's the same fail-open the shape check was added to close, arriving through the half of its own contract it wasn't enforcing — a good catch on a function added specifically to prevent that failure.

The declared version is now verified as a finite number ≥ 2. The table covers absent, non-numeric ("3"), and declared-v1-carrying-packages.

npm run validate passes; test:scripts at 74.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread scripts/verify-dep-lockstep.mjs Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@cliffhall
cliffhall merged commit 16b9eff into v2/main Aug 11, 2026
14 checks passed
@cliffhall
cliffhall deleted the v2/chore/zod-version-lockstep branch August 11, 2026 15:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(deps): zod 4.4 blows the tsc heap in clients/web — held at ~4.3.6

2 participants