Repository navigation
fix(peers): keep a blocker that only an unpacked major accepts, with a scoped peer override (UPT-125) - #266
Conversation
…a scoped peer override (UPT-125) peerPreflight suggested every blocker's first accepting release as an extra upgrade, and the agent-led continuation took that plan: redis 5 → 6 moved typeorm 0.3.31 → 1.1.0, a major no pack migrates. A blocker that only such a major accepts now keeps its version with the peer override `--allow-peer` writes (npm overrides, pnpm peerDependencyRules, Yarn resolutions), inside the lockfile scope check. The PR body names the override and the major not taken; report.json records `declined`, `override` and `strategy` (override, blocked when verification fails with it, none). A named or packed blocker is upgraded as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
|
Review complete. 🟡 1 medium 💬 Inline comments (1)
🧹 Nitpicks (1) — 🟢 1 low
The change introduces a decline/override loop for unresolvable peer majors in
Reviewed commit: 30b580b |
There was a problem hiding this comment.
This PR adds a peer-dependency override flow that records declined peer majors, rewrites package-manager overrides, and reports strategy/declined/override fields, with two correctness issues worth fixing.
Key findings
- 🟡 Pack-covered advice shown for majors with no pack — peer-preflight.ts:161
| : p.via | ||
| ? `rejects installed ${installed}: upgrade ${p.peers.map((r) => r.peer).join(', ')} into range first, or use --allow-peer ${p.name}` | ||
| : major | ||
| ? `no ${major.major}.x accepts ${accepts}; ${p.newer} does (major, the ${p.name} pack runs in the same fix): add ${p.name} to the command` |
There was a problem hiding this comment.
🟡 bug · medium
Pack-covered advice shown for majors with no pack
The rewritten advice in describe() claims "the ${p.name} pack runs in the same fix" for every major move, dropping the previous major.pack === false distinction (packages/core/src/fix/peer-preflight.ts:161). decline() only intercepts blockers that are neither via-chased nor explicitly requested, so an extra named with --also whose accepting release is an unpacked major reaches the major branch with majorMove(...).pack === false and is told its pack runs in the same fix — but no pack migrates it. The companion row for the same move says the opposite (add() writes migration: 'own' with "no migration pack: it needs its own migration"), so the emitted command contradicts itself and the --allow-peer fallback hint is gone.
📋 Prompt for AI Agents
In packages/core/src/fix/peer-preflight.ts, in describe() at line 161, the major-move branch of the advice ternary always renders "the ${p.name} pack runs in the same fix". Restore the distinction the previous version had: when major.pack is false, render "no migration pack: it needs its own migration" and keep the or keep ${major.major}.x with --allow-peer ${p.name} fallback. Then in packages/core/src/fix/peer-preflight.test.ts, restore the it.each case ('covered: no pack covers the major') that this patch deleted, asserting the pack-less wording for a blocker named on the command line (which decline() does not intercept).
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing). If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## uptide@0.18.2 ### Patch Changes - [#267](#267) [`907dffb`](907dffb) Thanks [@lucasbraum](https://github.com/lucasbraum)! - The agent can no longer change dependencies outside the upgrade. An agent edit that changes or removes an existing dependency in any `package.json`, or touches `overrides`, `resolutions` or the `pnpm` field, is refused and reverted. Uptide sends the refusal back to the agent in the next feedback round, naming the package, and records it in `report.json` (`outOfScopeDependencyEdits`). That includes the upgraded package and its planned companions, whose versions the engine wrote: only a rewrite to the same value is allowed. The agent may still add a new dependency: it is kept, recorded (`agentAddedDependencies`) and listed under "Added by the agent" in the PR body. Lockfiles, `.npmrc`, `.yarnrc(.yml)`, `.pnpmfile.cjs`, `pnpm-workspace.yaml` and new `package.json` files are read-only for the agent. - [#267](#267) [`907dffb`](907dffb) Thanks [@lucasbraum](https://github.com/lucasbraum)! - A companion that would move a new major is planned only when the upgraded package asks for it: its own manifest at the target pins the companion, the companion's manifest declares the upgraded package, or the upgraded package's pack names it. When a third package asks for it (`@guardian/libs` 33 pinning `@guardian/ophan-tracker-js` 3.1.0 during a typescript upgrade), Uptide no longer moves it. The PR lists it under "Blocked" with the declaring package, the manifest field and the range. Release pairs still move with their lead: `react-dom` with `react`, `@ai-sdk/*` with `ai`, `@sentry/profiling-node` with `@sentry/node`. - [#267](#267) [`907dffb`](907dffb) Thanks [@lucasbraum](https://github.com/lucasbraum)! - A package that does not move no longer drags another one through its pins: `fix typescript` on guardian/support-dotcom-components moved `@guardian/ophan-tracker-js` 2.8.3 → 3.1.0 because `@guardian/libs` 33.0.0, which stayed, pins it. A pin the same manifest also declares as a peer the install satisfies moves nothing either, and a pin reason now names the manifest field and the installed version. - [#266](#266) [`a5e3f57`](a5e3f57) Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide fix` no longer upgrades a peer blocker across a major that no migration pack covers. Before, `redis` 5 → 6 next to `typeorm` 0.3 (which peers on redis ^5) moved typeorm to 1.1.0 and ended in an agent-led draft. Now the blocker keeps its version, with a peer override scoped to it in package.json: npm `overrides.<blocker>.<peer>`, pnpm `pnpm.peerDependencyRules.allowedVersions`, Yarn `resolutions["<blocker>/<peer>"]`. The lockfile scope check still covers the install. The PR body names the override, which release was not taken and why. In `report.json`, each `peerConflicts` entry now has `strategy` (`override`, `blocked` or `none`), `declined` and `override`. If verification fails with the override, the blocker is reported as a blocker and is still not upgraded. A blocker you name on the command line, or one whose major a pack covers, is upgraded as before. - [#264](#264) [`2089d0a`](2089d0a) Thanks [@lucasbraum](https://github.com/lucasbraum)! - `uptide fix` no longer stops when the package manager resolves a different version than the target. Yarn classic ignored the pin for a direct dependency and resolved a range such as `^7.0.105` to its newest match (7.0.146). Now the lockfile resolves exactly the target with npm, pnpm, Yarn classic and Yarn Berry, and the manifest keeps its range. If the package manager still resolves another version, the run continues only when that version is in the same major, inside the manifest range and covered by the pack. The PR body, the terminal summary and `report.json` (`resolvedVersion`, `lockfile.resolved`) then name both versions. Otherwise the run stops with an error that names the target, the resolved version and the reason. - [#268](#268) [`4cee03a`](4cee03a) Thanks [@lucasbraum](https://github.com/lucasbraum)! - `fix` computes the agent's cost itself, per model and token class, from Uptide's price table: `report.json` and `--json` list each model's uncached input, cache read, 5-minute and 1-hour cache write and output tokens, the rate applied to each and the cost, so the total can be redone by hand. The Agent SDK's own figure is kept apart as `sdkReportedCostUsd`; `--max-cost` counts the higher of the two, a gap above 5% is printed as a warning, and a model missing from the table uses the SDK's figure, marked unverified. `llm.inputTokens` now counts uncached input only. - [#268](#268) [`4cee03a`](4cee03a) Thanks [@lucasbraum](https://github.com/lucasbraum)! - Sonnet 5.5 cache reads are priced at the published $0.10 per million tokens (0.05x input), not $0.20. Every Anthropic rate in the price table was checked against Anthropic's pricing page on 2026-10-20, and a test now pins each one. Co-authored-by: uptide-release[bot] <338861287+uptide-release[bot]@users.noreply.github.com>
Fixes UPT-125.
Where the escalation came from: engine, not the agent
The typeorm major came from engine code, before any agent call. On main (2089d0a):
fix/peer-preflight.ts:389-401: when npm has an unallowed peer blocker, the look-ahead loop adds every blocker that has a newer accepting release (p.newer) toextras, the suggestedalsolist. It does this whether that release is a major and whether a pack covers it.fix/peer-preflight.ts:423:PeerPreflightErrorcarries that plan (plan: { also: ['typeorm'], allowPeer: [] }).fix/feedback.ts:233,assistedPeerPreflight, called fromfix/isolate.ts:598: an assisting run continues with{ ...options, ...error.plan }. typeorm becomes a companion at 1.1.0 withmigration: 'own'(peer-preflight.ts:346), and the run turns into an agent-led draft.The agent never chose typeorm, so the fix is in the engine and there is no agent guardrail (UPT-126 is untouched).
What changed
peerPreflightdeclines a blocker when the only release that accepts the peer is a major no pack migrates (majorMove(...).pack === false), it is not a transitiveviachain, and the user did not name it. The blocker is added to the allowed set, the same path as--allow-peer, and the major it would have taken is recorded asdeclined. The look-ahead does the same for blockers it finds later. A blocker named on the command line, or one whose major a pack covers (express 4 → 5), moves as before.writePeerOverrides/allowPeerOverrideswrites it, scoped to the blocker: npmoverrides.<blocker>.<peer>($<peer>when the peer is a direct dependency), pnpmpnpm.peerDependencyRules.allowedVersions["<blocker>><peer>"], Yarn classic and Berryresolutions["<blocker>/<peer>"]. The blocker's name goes to the install asallowedPeers, so theLOCKFILE_OUT_OF_SCOPEguard allows only its subtree to move and refuses anything else.peerConflictsgroup inreport.jsongetsdeclined,override(the package.json field) andstrategy.strategyisoverride;blockedwhen the override is in place and verification fails, in which case the blocker is still not upgraded; ornone(not overridden: pnpm or Yarn left warning, as before).Uptide added a peer override to package.json: \overrides.typeorm.redis`. 1.1.0 accepts redis 6.3.0 but is a major with no migration pack, so Uptide did not upgrade typeorm; it stays at 0.3.31.When blocked:Blocker: … Tried: a peer override Uptide added to package.json (…); verification failed with it. … it needs its own migration.The terminal summary and the agent-led reason text (blockerOutcome`) say the same.bench/,pricing.ts, release scripts, the Action pin, golden workflows,schema/uptide.run.v1.json.Repro: raouf-b-dev/ecommerce-store-api at
c9cd358(UpgradeBench v1redis-ecommerce-store-api, parent of the upgrade), npmClone used read-only; nothing pushed, no comments. Command:
Before (main 2089d0a): exit 1, $0.00, agent 0s. The typeorm major comes from the engine plan:
After (this branch): exit 0, $0.00, not agent-led, typeorm kept:
Branch diff:
package.json(redis^6.3.0andoverrides.typeorm.redis = "$redis") andpackage-lock.json. This is the same override the human upgrade (PR 310) added:report.json:
{"peerConflicts":[{"name":"typeorm","version":"0.3.31","peers":[{"peer":"redis","range":"^3.1.1 || ^4.0.0 || ^5.0.14","target":"6.3.0","version":"0.3.31"}],"allowed":true,"declined":"1.1.0","newer":"1.1.0","override":"overrides.typeorm.redis","strategy":"override"}],"passed":true}lockfile.outOfScopeis absent: the scope check found nothing outside the redis and typeorm subtrees.Tests
fix/peer-override.test.ts(new), fullfix()runs. In each,plugin1.0.0 peers on zod ^3 and only plugin 2.0.0, a major with no pack, accepts zod 4:^1.0.0; the install getsallowedPeers: ['plugin']and noalso; there are no companions and verification passes;peerConflictshasdeclined: '2.0.0'andstrategy: 'override'; the PR body names the field.strategy: 'blocked', and the PR body has the**Blocker:**line with what was tried.fix/managers/compatible-ranges.test.ts(UPTIDE_NETWORK=1, real registry): for npm, pnpm 10.17.1, Yarn 1.22.22 and Yarn 4.9.2, typeorm 0.3.31 with redis ^5.8.0 upgrades to redis 6.3.0 with Uptide's override andallowedPeers: ['typeorm']. Each install passes, redis is 6.3.0, typeorm stays 0.3.31,outOfScopeis undefined, and a frozen install leaves the lockfile unchanged. For npm, the same upgrade without the override is refused (ERESOLVE). The whole matrix passes:Tests 18 passed (18).unpackedMajorforced tofalse:Tests 5 failed (5)inpeer-override.test.ts. npm stops in preflight; pnpm and Yarn write no override.strategy: 'blocked'replaced by'override':Tests 1 failed | 4 passed (5), the blocked case.peer-preflight.test.ts, the "no pack covers the major" case expectedplan.also: ['plugin']andmigration: 'own'; it is removed, and the new tests cover that behavior.--no-llmstop and the UPT-102 "first accepting release" tests now use a packed blocker (express 4 → 5), which keeps their intent.--allow-peertest now also expectsstrategyandoverrideinpeerConflicts.Local:
Open questions
pnpm.overrides. This PR keeps what--allow-peeralready writes for pnpm,pnpm.peerDependencyRules.allowedVersions. That is pnpm's setting for accepting a peer range, whilepnpm.overridesreplaces the version a dependency resolves to. The real pnpm 10.17.1 install above passes with it. Switch topnpm.overridesif you prefer.blocked. It does not try anything else; the only other strategy today would be the unpacked major, which is now refused by default.--allow-peer, for every manager, including pnpm and Yarn, which before only warned. Same-major and packed-major blockers behave as before.typeorm 0.3.31 → 0.3.31for the allowed peer's moved subtree. That line already appeared this way for--allow-peerruns and is not changed here.🤖 Generated with Claude Code