chore(scripts): derive dep-lockstep candidates from what enters each tsc program - #2029
Conversation
…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>
There was a problem hiding this comment.
Pull request overview
Refactors dependency lockstep validation to derive candidates from actual TypeScript program contents, addressing #1965.
Changes:
- Adds shared
tsc --listFilesOnlyprogram-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.
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>
|
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. 2. Folding a nested copy discarded the path that identifies the version loaded. Genuine fail-open. Failure output now reads:
|
Closes #1965
The gap
verify:dep-lockstepderived 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.tswas 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/sdkis 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.tsfiles land inclients/web's test program — verified on this tree.The new derivation
For each client, every tsconfig project (from its
typecheckscript, or itstsconfig.jsonreferencesfor atsc -bclient likeclients/web) is listed withtsc --listFilesOnly. Each resolvednode_modulespath is mapped to:node_modulessegment (.,clients/web), so a nestednode_modules/a/node_modules/bfolds onto its outermost install (npm resolving a transitive conflict inside one install is routine, and the lockfile comparison ignores it for the same reason);node_modulessegment.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_SKEWas a name allowlist scoped to within-a-major.Two properties worth calling out, both encoded in tests:
name@version, and a redirected.d.tsdoes 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 bothverify:typecheck-coverageandverify:dep-lockstep, so the two guards cannot disagree about what a program contains. It owns the tsc-script parsing, the tsconfig reference graph, the--listFilesOnlyinvocation (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/mainis 3 packages —zod,pino,@standard-schema/spec— and all three are aligned. No new skew needed a decision. Specifically:@modelcontextprotocol/sdkclients/web's program loads it, from the root install alone1.30.0and 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@modelcontextprotocol/ext-appsreactreactships no types of its own — what lands in a program is@types/react, from one install. Thereactpackage's own files never enter a program, so the lockfile difference cannot reach the type checker.honoclients/webonly in web's node program.TOLERATED_SKEWis 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
tsclisting exited non-zero or resolved nothing, are now reported rather than silently contributing no candidates.Timings
verify:dep-lockstepverify:typecheck-coveragetest:scriptsNet
validatecost: ~+13s. (The issue's ~30–60s estimate was pessimistic; measured, one listing pass is ~10–14s.)Tests
scripts/lib/tsc-program.test.mjs:classifyModulePath(outermost install / innermost package, scopes, nested copies, npm bookkeeping paths, segment-vs-substring matching) andcrossInstallPackages(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.mjsfixtures now build a real two-install tsc program: a clientsrcresolving from the client install, a sharedcore/tree resolving from the root, and stub packages whose installed versions match their lockfile entries (TypeScript keys its dedup onname@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.clientProjectscases 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 rootREADME.mdtesting table, and the.github/copilot-instructions.mdmirror all describe the new derivation. TheKNOWN BOUNDARY (#1965)note at the top of the guard is gone.Gate
npm run formatthennpm run cifrom the root — validate, coverage,verify:build-gate, smoke, and Storybook all pass. One run hit asmoke:webport collision with a long-running local Vite dev server holding 6274/6275; re-runningnpm run smokeafter it passed clean, and the failure is unrelated to this change.