Skip to content

chore(scripts): derive dep-lockstep candidates from what enters each tsc program - #2029

Merged
cliffhall merged 2 commits into
v2/mainfrom
v2/chore/1965-dep-lockstep-tsc-derivation
Aug 16, 2026
Merged

chore(scripts): derive dep-lockstep candidates from what enters each tsc program#2029
cliffhall merged 2 commits into
v2/mainfrom
v2/chore/1965-dep-lockstep-tsc-derivation

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #1965

The gap

verify:dep-lockstep derived its candidate set from the packages the shared first-party sources (core/, test-servers/src, vitest.shared.mts) named directly. A package whose declarations reach a program only through another package's .d.ts was invisible to it — so it could skew across installs and recreate the two-copy blow-up #1896 was about while the guard stayed green.

@modelcontextprotocol/sdk is the live example: it is never written in first-party code (the shared sources import the split @modelcontextprotocol/client|core|…), yet 16 of its .d.ts files land in clients/web's test program — verified on this tree.

The new derivation

For each client, every tsconfig project (from its typecheck script, or its tsconfig.json references for a tsc -b client like clients/web) is listed with tsc --listFilesOnly. Each resolved node_modules path is mapped to:

  • its install root — the prefix before the first node_modules segment (., clients/web), so a nested node_modules/a/node_modules/b folds onto its outermost install (npm resolving a transitive conflict inside one install is routine, and the lockfile comparison ignores it for the same reason);
  • its package — the name after the last node_modules segment.

A package that reaches one program from two installs is a candidate. That is precisely the set that can put two structurally-distinct copies of one type in front of a single checker. Everything downstream is unchanged: top-level lockfile comparison, deny-by-default, TOLERATED_SKEW as a name allowlist scoped to within-a-major.

Two properties worth calling out, both encoded in tests:

  • A package present in two installs but reached from only one in a given program is correctly not a candidate. Nothing has to relate two copies the checker only ever loads one of.
  • TypeScript's package-identity redirect collapses two copies at the same name@version, and a redirected .d.ts does not resolve its own imports — so an aligned package's transitive dependencies load once and never become candidates. The redirect stops applying the moment the pair skews, at which point both copies enter the program and the package becomes a candidate. The measure is self-correcting rather than leaky.

The rejected alternative from the issue (expanding direct imports over the lockfiles' dependencies) stays rejected: 155 packages, 25 skewed, nearly all irrelevant transitive tooling — and it missed the SDK anyway.

Shared helper

The listing machinery moved to scripts/lib/tsc-program.mjs, now read by both verify:typecheck-coverage and verify:dep-lockstep, so the two guards cannot disagree about what a program contains. It owns the tsc-script parsing, the tsconfig reference graph, the --listFilesOnly invocation (memoized per project, per process), and the two slices the guards want (first-party files / installed files).

It is deliberately not cached to disk between the two guard processes. A fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss — the gate failing open, the one way these gates must never fail. The measured cost of the second pass is ~14s, which does not buy that risk. Timings below.

Skews surfaced, and the disposition of each

Under the new derivation the candidate set on v2/main is 3 packageszod, pino, @standard-schema/spec — and all three are aligned. No new skew needed a decision. Specifically:

Package Old derivation Now Disposition
@modelcontextprotocol/sdk invisible (the #1965 gap) not a candidate — only clients/web's program loads it, from the root install alone Already aligned by #1970, which moved the SDK packages to root-only; root holds 1.30.0 and no client declares it. The 1.29.0/1.30.0 split the issue cites is gone. The new derivation would catch its return.
jose allowlisted not a candidate Allowlist entry removed. Root-only since #1970, so it cannot skew at all; an entry for it is a rationale for an impossible skew, and removing it re-arms the guard on the mistake #1970 was about.
@modelcontextprotocol/ext-apps allowlisted not a candidate Allowlist entry removed, same reason.
react allowlisted (19.2.7 root / 19.2.8 web) not a candidate Allowlist entry removed. react ships no types of its own — what lands in a program is @types/react, from one install. The react package's own files never enter a program, so the lockfile difference cannot reach the type checker.
hono allowlisted (4.12.25 / 4.12.33) not a candidate Allowlist entry removed. Verified it resolves from clients/web only in web's node program.

TOLERATED_SKEW is therefore empty today, with the reasoning recorded on the constant. That is a consequence of measuring the right thing, not a relaxation — every removed entry described a skew that cannot occur, and any package that does start reaching one program from two installs fails the guard and forces a decision then, with the real version pair in hand.

The guard also gained two fail-closed checks the old shape didn't need: a client that names no tsconfig project, and a program whose tsc listing exited non-zero or resolved nothing, are now reported rather than silently contributing no candidates.

Timings

before after
verify:dep-lockstep 0.5s 10.0s
verify:typecheck-coverage 13.1s 13.3s
test:scripts 3.7s (87 tests) 7.8s (85 tests — the main-path fixtures now build real tsc programs)

Net validate cost: ~+13s. (The issue's ~30–60s estimate was pessimistic; measured, one listing pass is ~10–14s.)

Tests

  • New scripts/lib/tsc-program.test.mjs: classifyModulePath (outermost install / innermost package, scopes, nested copies, npm bookkeeping paths, segment-vs-substring matching) and crossInstallPackages (two installs in one program; two installs across separate programs is not a candidate; a nested duplicate is not; every program that saw both copies is recorded). The tsc-script and tsconfig-graph cases moved here with their code, tags intact.
  • verify-dep-lockstep.main.test.mjs fixtures now build a real two-install tsc program: a client src resolving from the client install, a shared core/ tree resolving from the root, and stub packages whose installed versions match their lockfile entries (TypeScript keys its dedup on name@version, so a lying stub would test a different program). New cases cover the chore: derive verify:dep-lockstep candidates from what actually enters each tsc program #1965 regression (a package reached only through another package's .d.ts), the deliberate narrowing (a package reaching the program from one install is not a candidate), an unmeasurable program, a client naming no project, and no client program at all.
  • clientProjects cases in the unit suite pin the enrollment rule, including that a --noCheck-neutered project still contributes its program here (whether it type-checks is the sibling guard's question).

Docs

AGENTS.md (both the pre-push-gate list and the "One version per install-crossing dependency" section), the root README.md testing table, and the .github/copilot-instructions.md mirror all describe the new derivation. The KNOWN BOUNDARY (#1965) note at the top of the guard is gone.

Gate

npm run format then npm run ci from the root — validate, coverage, verify:build-gate, smoke, and Storybook all pass. One run hit a smoke:web port collision with a long-running local Vite dev server holding 6274/6275; re-running npm run smoke after it passed clean, and the failure is unrelated to this change.

…tsc program

`verify:dep-lockstep` derived its candidate set from the packages the shared
first-party sources named *directly*, so a package whose declarations reach a
program only through another package's `.d.ts` was invisible to it — it could
skew across installs and recreate the two-copy blow-up #1896 was about while
the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never
written in first-party code, yet 16 of its `.d.ts` files land in clients/web's
test program.

Replace the derivation with the measured one: list every client tsconfig
project with `tsc --listFilesOnly`, map each resolved `node_modules` file to
its owning install and package, and keep the packages that reach ONE program
from TWO installs. That is exactly the set that can put two structurally
distinct copies of a type in front of one checker. The rest of the guard is
unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as
a name allowlist scoped to within-a-major.

The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with
`verify:typecheck-coverage`, so the two guards can't disagree about what a
program contains. It is deliberately not cached to disk between the two
processes: a stale fingerprint would make a guard measure a program that no
longer exists and pass on a real miss. The second pass costs ~14s.

`TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are
root-only since #1970 and cannot skew; `react` (whose types are `@types/react`,
resolved from one install) and `hono` never reach one program from two
installs. Each would be a rationale for a skew that cannot occur.

Closes #1965

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 16, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 16, 2026 16:18

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

Refactors dependency lockstep validation to derive candidates from actual TypeScript program contents, addressing #1965.

Changes:

  • Adds shared tsc --listFilesOnly program-analysis utilities.
  • Updates dependency-lockstep validation and tests.
  • Documents the revised validation model.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
scripts/lib/tsc-program.mjs Adds shared TypeScript program analysis.
scripts/lib/tsc-program.test.mjs Tests program and package classification.
scripts/verify-dep-lockstep.mjs Derives candidates from loaded package files.
scripts/verify-dep-lockstep.test.mjs Updates lockstep helper tests.
scripts/verify-dep-lockstep.main.test.mjs Adds realistic end-to-end fixtures.
scripts/verify-typecheck-coverage.mjs Uses the shared program helper.
scripts/verify-typecheck-coverage.test.mjs Relocates shared-helper tests.
README.md Updates validation documentation.
AGENTS.md Documents architecture and guard behavior.
.github/copilot-instructions.md Mirrors dependency-validation guidance.

💡 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 scripts/lib/tsc-program.mjs
Two findings from the Copilot review of #1965, both real.

1. The candidates were flattened to package names before the version
   comparison, so a third install holding a different copy failed the guard
   even when no program loads that copy beside another — and the diagnostic
   named an install that never took part. `crossInstallPackages` now returns
   the co-occurrences themselves (name → program → install → entry paths) and
   `findSkew` compares per program, across only the installs that met there.

2. Folding a nested copy onto its outermost install lost the path that
   identifies which copy was loaded, and the version was then read from the
   install's top-level entry — absent or differently versioned, either way
   letting a real pair pass. `classifyModulePath` now also returns the
   `entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes
   `lockVersionsByPath`, and each holder is priced from the entry the program
   resolved. A resolved copy with no lockfile entry is now a hard failure
   rather than a silently dropped holder.

The failure output prints `in <program>` with each holder's install path
beside its version, so a nested copy is distinguishable from a top-level one.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 1 — both findings implemented (inline replies get hidden once the branch moves, so mirroring here).

1. Flattening the candidates to names lost which installs co-occurred. Real false-positive class. crossInstallPackages now returns the co-occurrences themselves — Map<name, Map<program, Map<installRoot, Set<entryPath>>>> — and findSkew compares versions per program, across only the installs that met there. A third install whose copy no program loads beside another is no longer a finding, and the diagnostic can no longer name an install that never took part. New cases: findSkew: only the installs that MET in a program are compared and, end-to-end, main: a third install's copy is not dragged into a comparison it never joined (a real third client install whose program spans only itself).

2. Folding a nested copy discarded the path that identifies the version loaded. Genuine fail-open. classifyModulePath now also returns entryPath — the key npm writes in that install's lockfile (node_modules/a/node_modules/zod) — so the folding that keeps the candidate set small is retained while the version is read from the copy that actually entered the program. topLevelLockVersionslockVersionsByPath (keyed by install path), and each holder is looked up by (install, entryPath). A resolved copy with no lockfile entry is now a hard failure rather than a dropped holder. New cases: lockVersionsByPath: keyed by install path…, findSkew: a nested copy is priced from its own entry, findSkew: a copy with no lockfile entry is reported, not skipped, crossInstallPackages: a nested copy is kept as its own entry path, and end-to-end main: a nested copy is priced from its own lockfile entry — a fixture where the root reaches inner only via node_modules/outer/node_modules/inner@9.9.9 while its top-level entry still reads the aligned 4.5.6, i.e. exactly the case that used to pass.

Failure output now reads:

  inner
    in clients/web/tsconfig.json
      9.9.9  (./node_modules/outer/node_modules/inner)
      4.5.6  (clients/web/node_modules/inner)

AGENTS.md and the README row updated to match. npm run format + npm run ci green (90 script tests, coverage, build-gate, smokes, Storybook).

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 10 changed files in this pull request and generated no new comments.

@cliffhall
cliffhall merged commit 6bc355d into v2/main Aug 16, 2026
4 checks passed
@cliffhall
cliffhall deleted the v2/chore/1965-dep-lockstep-tsc-derivation branch August 16, 2026 21:37
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: derive verify:dep-lockstep candidates from what actually enters each tsc program

2 participants