chore: widen verify:dep-lockstep to declared cross-install copies - #2260
Conversation
) `clients/cli` resolved `@types/node` 24.13.1 against the root's 24.13.3. Neither client declares it — #2196 consolidated it to the root — so the cli copy arrived transitively via `@types/express` and was constrained by no range of ours. `verify:dep-lockstep` reported OK and was right to: it compares only packages that reach one `tsc` **program** from two installs, and no one program sees both copies. Fixes the instance and the class, which the issue notes are not exclusive. **The class.** The guard grows a second tier alongside the program-derived one, asking the weaker but broader question `AGENTS.md`'s "one version per install-crossing dependency" rule actually states: does a package this repo declares anywhere resolve to two versions across our installs at all? The candidate set is every name in any install's `dependencies`/`devDependencies` that more than one install holds a **top-level** copy of — 17 packages today. Nested copies are excluded: one exists because some dependency asked for a different version, so it is that dependency's range, not ours. Neither tier subsumes the other. The program tier sees a copy no manifest names (the `@modelcontextprotocol/sdk` case, arriving through another package's `.d.ts`). The declared tier sees a transitive copy no program loads (this issue), two *clients* disagreeing with no root copy involved (`@types/react`, web against tui), and the peer shadows `AGENTS.md` calls out as ungated — `eslint`, `typescript`, `vitest`. Deny-by-default, with its own empty `TOLERATED_DECLARED_SKEW` and the same within-a-major rule, now stated once in a shared `toleratesSkew` so the two tiers cannot drift. **The instance, and three others the new tier surfaced.** All four were patch-level: - `@types/node` — cli 24.13.1 vs root 24.13.3. Transitive, so it takes an `overrides` entry in `clients/cli` (the repo's sanctioned lever for a transitive pin), at the root's own `^24.12.4` rather than a frozen number. - `@types/react` — web 19.2.18 vs tui 19.2.17. - `react` — web 19.2.8 vs root/tui 19.2.7. - `@vitejs/plugin-react` — web 6.0.5 vs root 6.0.2. The last three are `npm update` in the stale install: it moves the lockfile within the declared range without widening a range, which matters for the root `react ^19.0.0` that `ink`'s externalized-bundle exemption depends on. No declaration or range changed. Tests: 4 end-to-end cases (the fixture gains manifest deps and a client-side `solo` install, so the skew is real and no program holds both copies) and 13 helper cases. Closes #2226 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate coverage gaps and two documentation nits remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a second dependency lockstep tier to detect version skew across installations.
Changes:
- Detects declared-package skew across top-level installs.
- Aligns four dependency versions and pins CLI’s transitive
@types/node. - Adds helper/end-to-end tests and updates policy documentation.
Required changes:
- Moderate (2 votes): Include
optionalDependenciesin candidate detection and update tests. - Moderate (2 votes): Adjust the
solofixture to test an undeclared transitive client copy. - Nit (1 vote each): Update stale guidance in
.claude/skills/local-dev/SKILL.mdanddocs/quality-gate.md.
File summaries
| File | Description |
|---|---|
scripts/verify-dep-lockstep.test.mjs |
Tests declared-package skew helpers. |
scripts/verify-dep-lockstep.mjs |
Implements the second guard tier; optional dependencies are currently omitted. |
scripts/verify-dep-lockstep.main.test.mjs |
Adds end-to-end cases; the key transitive-copy fixture needs correction. |
package-lock.json |
Aligns React and Vite plugin versions. |
clients/web/package-lock.json |
Aligns the Vite React plugin. |
clients/tui/package-lock.json |
Aligns React and its types. |
clients/cli/package.json |
Overrides transitive @types/node. |
clients/cli/package-lock.json |
Aligns @types/node. |
AGENTS.md |
Documents the two-tier guard; related guidance remains stale. |
Review details
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)
AGENTS.md:95
- The new statement leaves the repository's dependency-troubleshooting skill contradictory:
.claude/skills/local-dev/SKILL.md:217-223still says that nothing gates peer/transitive copies and thatverify:dep-lockstepcannot catch them. Update that section with the new second-tier behavior and remaining limits so loading the skill does not give stale guidance.
- ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit *nearer* than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned, and nothing gates them.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **Since #2226 `verify:dep-lockstep` does catch this class** — its second tier compares every package any install *declares* against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update <pkg>` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way).
scripts/verify-dep-lockstep.mjs:633
- The command's canonical documentation is now stale:
docs/quality-gate.md:32anddocs/quality-gate.md:42still describeverify:dep-lockstepas checking only dependencies that meet in onetscprogram. Update that entry to cover this declared-package tier and its lockfile-based scope so developers can understand and remediate its new failures.
// ---- Second tier: every DECLARED package held by more than one install.
// Independent of what any `tsc` program loads, so it sees the transitive and
// peer-shadow copies the program tier structurally cannot (#2226).
- Files reviewed: 5/9 changed files
- Comments generated: 2
- Review effort level: Balanced
- Include `optionalDependencies` in the declared tier's candidate union. A peer range constrains the consumer's host; an optional range is a direct declaration npm attempts to install, so omitting it left an optional-only package free to skew unseen. `peerDependencies` stays excluded, with the reason stated. - Make the end-to-end fixture's client copy UNDECLARED — only the root manifest names `solo`, while `clients/web` merely holds a top-level copy. That is #2226's defining shape, and it is what makes the candidate set a union across installs rather than a per-install intersection; the old fixture would have passed a per-install implementation. Adds the mirror case (declared only by a client, held by the root) so the union is pinned in both directions. - Update the two docs that still described the single-tier guard: `.claude/skills/local-dev/SKILL.md` said "nothing gates either of those, and `verify:dep-lockstep` is not it" and cited the live `@types/node` drift in the present tense; `docs/quality-gate.md` described only the program tier. Both now state what each tier covers and the two limits that remain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1 — all four addressedPushed as Moderate — include Moderate — the Nit — stale guidance in Nit — stale guidance in Frontmatter descriptions were not touched, so no
|
There was a problem hiding this comment.
🔵 Needs a closer look
The guard’s documentation must accurately include optionalDependencies; minor documentation issues also remain.
Review details
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)
AGENTS.md:95
- This paragraph still says “Nothing keeps the surviving copies aligned, and nothing gates them,” but later in the same paragraph says the new second tier gates exactly those copies. Update the earlier sentence to distinguish automatic alignment from detection (for example, nothing keeps them aligned automatically, but the guard now rejects drift) so the rule is not self-contradictory.
- ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit *nearer* than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. ⚠️ **Nothing keeps the surviving copies aligned, and nothing gates them.** A **peer** copy is at least constrained by its holder's peer range — tightly for `vitest` (an exact peer, hence the pin below), loosely for `eslint` (`^9 || ^10`), where the copies agree only because npm resolves the same latest in both installs. A **transitive** copy is constrained by nothing of ours at all, and cli's `@types/node` (`24.13.1` against the root's `24.13.3`) diverged on exactly that. **Since #2226 `verify:dep-lockstep` does catch this class** — its second tier compares every package any install *declares* against every top-level copy across all five installs, independent of what a `tsc` program loads, so a transitive drift and a peer shadow (`eslint`, `typescript`, `vitest`) are both in scope now. Two limits remain: the tier reads lockfiles, so a tool binary you installed by hand and never committed is still invisible; and it only compares names some manifest declares, so a purely transitive package no manifest names is out of scope in both tiers unless a `tsc` program loads both copies. Aligning a stale install is `npm update <pkg>` there; a transitive copy that will not move takes an `overrides` entry in that install (`clients/cli` pins `@types/node` this way).
docs/quality-gate.md:44
- This paragraph interrupts the Markdown table before the
local:gateandpack:verifyrows. Those rows will render as pipe-delimited text rather than as part of the table. Keep theverify:dep-lockstepsummary within its table cell, or move this detailed explanation after the table so all script rows remain contiguous.
A **second tier** (#2226) runs alongside it, asking the weaker but broader question the `AGENTS.md` rule actually states: does a package this repo *declares* anywhere resolve to two versions across our installs at all? Its candidate set is every name in any install's `dependencies`/`devDependencies`/`optionalDependencies` — unioned across the root and all four clients, so a copy declared by only one of them still counts — that **more than one install holds a top-level copy of** (17 packages today). Nested copies are excluded: one exists because some dependency asked for a different version, so it is that dependency's range to govern, not ours. Neither tier subsumes the other — the program tier sees a copy no manifest names (`@modelcontextprotocol/sdk`, arriving through another package's `.d.ts`), while the declared tier sees a **transitive** copy no program loads (cli's `@types/node`, hoisted via `@types/express` — the case that motivated it), two **clients** disagreeing with no root copy involved (`@types/react`, web against tui), and the peer shadows `eslint`/`typescript`/`vitest` that never enter a program. Same deny-by-default and same within-a-major rule, against its own `TOLERATED_DECLARED_SKEW` — also empty. Two limits: it reads **lockfiles**, so an uncommitted hand-installed copy is invisible, and it compares only declared names, so a purely transitive package no manifest names stays the first tier's business.
scripts/verify-dep-lockstep.mjs:279
- The summary still names only two dependency fields even though the implementation and the detailed bullets now include
optionalDependencies. Keep the doc comment aligned with the candidate set.
* Every package name any install declares, across `dependencies` and
* `devDependencies`.
scripts/verify-dep-lockstep.mjs:62
- The module-level description also omits
optionalDependencies, so it documents a narrower candidate set than the implementation now enforces. Include the third field here as well.
// different versions across our installs at all? The candidate set is every name
// in any install's `dependencies`/`devDependencies` (root or client) that more
// than one install holds a top-level copy of — 17 packages today, against the
- Files reviewed: 7/11 changed files
- Comments generated: 0 new
- Review effort level: Balanced
All four suppressed comments, none with an inline thread to answer. - `docs/quality-gate.md`: the second-tier explanation was a paragraph *inside* the script table, which breaks the table — the `local:gate` and `pack:verify` rows below it would have rendered as pipe-delimited text. Folded back into the `verify:dep-lockstep` cell. - `verify-dep-lockstep.mjs`: the module header and `declaredPackages`' summary line both still named two fields after the implementation grew a third. Both now name all three, and the header says which field is excluded and points at the reason. - `AGENTS.md`: "Nothing keeps the surviving copies aligned, and nothing gates them" contradicted the sentence added later in the same paragraph. Split the two claims — nothing aligns them *automatically*, and the guard now rejects the drift; the bump is still by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 2 — all four addressedPushed as
|
There was a problem hiding this comment.
🔵 Needs a closer look
The repository-wide validation change warrants final human review, and two documentation inaccuracies remain.
Review details
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)
.claude/skills/local-dev/SKILL.md:130
- This documentation update leaves
.claude/skills/local-dev/SKILL.md:150stale: it still says web's React 19.2.8 is "against the root's 19.2.7", while this PR aligns the root to 19.2.8. Remove the volatile version comparison so the skill no longer describes a skew that this PR fixes.
the opportunity.
scripts/verify-dep-lockstep.mjs:288
- The claim that all four surfaced skews were devDependencies is inaccurate:
reactis declared only as a root runtime dependency, while its client copies are peer-installed. The field should still be included, but describe this as three of the four skews instead.
* • `devDependencies` — the `@types/*` and toolchain case #2226 is about; every
* one of the four skews this tier first surfaced was a devDependency
* somewhere.
- Files reviewed: 7/11 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Two factual corrections, both suppressed comments. - `.claude/skills/local-dev/SKILL.md`: the react/react-dom exception cited "19.2.8 today, against the root's 19.2.7" — a skew this PR removes. Dropped the version pair; the point being made is about which install resolves the pair, and it stands without a number that goes stale on the next bump. - `verify-dep-lockstep.mjs`: the `devDependencies` bullet claimed all four surfaced skews were a devDependency somewhere. `react` is a root runtime dependency whose client copies are peer-installed, so it is three of four — which is a better illustration of why the union spans fields as well as installs, and the bullet now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 3 — both addressedPushed as
Worth more than a number fix, so I rewrote the bullet rather than just changing "four" to "three":
|
There was a problem hiding this comment.
🟢 Approval recommended
The reviewed changes have no unresolved approval-blocking issues.
Review details
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
- Files reviewed: 7/11 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟢 Approval recommended
The reviewed changes have comprehensive coverage and no unresolved issues.
Review details
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
- Files reviewed: 7/11 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Closes #2226
clients/cliresolved@types/node24.13.1 against the root's 24.13.3. Neither client declares it — #2196 consolidated it to the root — so the cli copy arrived transitively via@types/express, constrained by no range of ours.verify:dep-lockstepreported OK and was right to: it compares only packages that reach onetscprogram from two installs, and no one program sees both copies.The issue offers three options and notes that (2) fixes the instance while (3) stops the next one, and that they are not exclusive. This does both, and declines (1) — re-declaring
@types/nodeinclients/cliwould re-create the second declaration #2196 removed.The class — a second guard tier
verify:dep-lockstepgrows a second tier alongside the program-derived one, asking the weaker but broader questionAGENTS.md's one version per install-crossing dependency rule actually states: does a package this repo declares anywhere resolve to two versions across our installs at all?The candidate set is every name in any install's
dependencies/devDependenciesthat more than one install holds a top-level copy of — 17 packages today. Nested copies are excluded on purpose: one exists because some dependency asked for a different version, so it is that dependency's range to govern, not ours.Neither tier subsumes the other.
.d.ts@modelcontextprotocol/sdk@types/react, web vs tuiAGENTS.mdcalls out as ungated (eslint,typescript,vitest)Deny-by-default, with its own (empty)
TOLERATED_DECLARED_SKEWand the same within-a-major rule — now stated once in a sharedtoleratesSkewso the two tiers cannot drift.Two limits are stated rather than papered over: the tier reads lockfiles, so a tool binary installed by hand and never committed is still invisible; and it compares only names some manifest declares, so a purely transitive package no manifest names stays the program tier's business.
The instance, and three others the new tier surfaced
All four were patch-level:
@types/nodeoverridesentry inclients/cli— the repo's sanctioned lever for a transitive pin, at the root's own^24.12.4rather than a frozen number@types/reactnpm updatein tuireactnpm updatein root + tui@vitejs/plugin-reactnpm updatein bothnpm updaterather thannpm install <pkg>@<version>: it moves the lockfile within the declared range without widening a range, which matters for the rootreact^19.0.0thatink's externalized-bundle exemption depends on. No declaration or range changed — the only manifest edit in the diff is cli'soverridesline.Verified against the reported state: reverting cli's lockfile entry to 24.13.1 makes the guard exit 1 and name all three holders.
Done when
clients/cliand the root resolve the same@types/nodeoverridespinnpm run local:gatepassesTests
main(). The fixture gains manifest dependencies and a client-sidesoloinstall, so the skew is real and no program holds both copies — the chore: clients/cli drifts to @types/node 24.13.1 via @types/express, and verify:dep-lockstep cannot see it #2226 shape exactly. Covers: skew with no program seeing it (fails), agreement (passes, counted), an installed package no manifest declares (not a candidate), a declared package only one install holds (cannot skew).declaredPackages(dev deps count, peer/optional don't),topLevelVersions(nested excluded),findDeclaredSkew(root-vs-client, client-vs-client, one holder, undeclared, sorting),partitionDeclaredSkewandtoleratesSkew(deny-by-default, within-a-major only, unparseable fails),countDeclaredHeld.Gate
npm run local:gate— every stage green. Two stages needed a re-run under machine contention from concurrent gate runs in other worktrees (load average 158): the web coverage timeouts were the documented 5s-timeout flake, with a different failing set each run and all of them passing in isolation, and one Storybook assertion (JsonObjectInput > Annotates The Offending Line) which passes 123/123 on a clean re-run. The diff touches no client source —git diff --name-onlyisAGENTS.md, threescripts/verify-dep-lockstep*files, cli'spackage.json, and four lockfiles.Docs
AGENTS.mdsaid this drift existed and that nothing caught it. Both sentences are updated to say what does now, and the "One version per install-crossing dependency" rule names both tiers.🤖 Generated with Claude Code
https://claude.ai/code/session_01J86a6vMcA9csPtdsg7ZcCZ