Skip to content

fix(peers): keep a blocker that only an unpacked major accepts, with a scoped peer override (UPT-125) - #266

Merged
lucasbraum merged 1 commit into
mainfrom
lucasbraum/upt-125-peer-dependency-blocker-escalates-to-an-unpacked-major
Oct 20, 2026
Merged

lucasbraum merged 1 commit into
mainfrom
lucasbraum/upt-125-peer-dependency-blocker-escalates-to-an-unpacked-major

Conversation

@lucasbraum

Copy link
Copy Markdown
Collaborator

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):

  1. 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) to extras, the suggested also list. It does this whether that release is a major and whether a pack covers it.
  2. fix/peer-preflight.ts:423: PeerPreflightError carries that plan (plan: { also: ['typeorm'], allowPeer: [] }).
  3. fix/feedback.ts:233, assistedPeerPreflight, called from fix/isolate.ts:598: an assisting run continues with { ...options, ...error.plan }. typeorm becomes a companion at 1.1.0 with migration: '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

  • peerPreflight declines a blocker when the only release that accepts the peer is a major no pack migrates (majorMove(...).pack === false), it is not a transitive via chain, 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 as declined. 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.
  • Override: the existing writePeerOverrides / allowPeerOverrides writes it, scoped to the blocker: npm overrides.<blocker>.<peer> ($<peer> when the peer is a direct dependency), pnpm pnpm.peerDependencyRules.allowedVersions["<blocker>><peer>"], Yarn classic and Berry resolutions["<blocker>/<peer>"]. The blocker's name goes to the install as allowedPeers, so the LOCKFILE_OUT_OF_SCOPE guard allows only its subtree to move and refuses anything else.
  • Report: each peerConflicts group in report.json gets declined, override (the package.json field) and strategy. strategy is override; blocked when the override is in place and verification fails, in which case the blocker is still not upgraded; or none (not overridden: pnpm or Yarn left warning, as before).
  • PR body (Peer risks): 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.
  • Not touched: the UPT-124 resolved-version logic, 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 v1 redis-ecommerce-store-api, parent of the upgrade), npm

Clone used read-only; nothing pushed, no comments. Command:

ANTHROPIC_API_KEY=invalid-placeholder uptide fix redis --target 6.3.0 --verbose --ci --keep-temp --max-cost 0.001

Before (main 2089d0a): exit 1, $0.00, agent 0s. The typeorm major comes from the engine plan:

uptide fix · redis 5.8.0 → 6.3.0 (requested) · verification failed: 21 type errors introduced by the upgrade · 3m 35s
  With      typeorm 0.3.31 → 1.1.0
  Risk      needs its own migration: typeorm 0.3.31 → 1.1.0 (major, no pack)
  Fallback  agent-led draft: Peer blockers: typeorm 0.3.31 → 1.1.0, the first release that accepts redis 6.3.0; continued as `npx uptide fix redis typeorm --target 6.3.0`

After (this branch): exit 0, $0.00, not agent-led, typeorm kept:

uptide fix · redis 5.8.0 → 6.3.0 (requested) · verification passed · 3m 10s
  Types     ✅ the upgrade broke 0 · Uptide left 0
  Tests     ✅ 1724 tests in 243 files passed · the test script of the repository
  Feedback  0/3 rounds · $0.00 of $0.00

Peer risks
  typeorm 0.3.31: redis ^3.1.1 || ^4.0.0 || ^5.0.14 rejects 6.3.0 · kept with a peer override (1.1.0 is a major with no migration pack)

Branch diff: package.json (redis ^6.3.0 and overrides.typeorm.redis = "$redis") and package-lock.json. This is the same override the human upgrade (PR 310) added:

-    "js-yaml": "^4.1.0"
+    "js-yaml": "^4.1.0",
+    "typeorm": {
+      "redis": "$redis"
+    }

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.outOfScope is absent: the scope check found nothing outside the redis and typeorm subtrees.

Tests

  • fix/peer-override.test.ts (new), full fix() runs. In each, plugin 1.0.0 peers on zod ^3 and only plugin 2.0.0, a major with no pack, accepts zod 4:
    • npm, pnpm, Yarn classic, Yarn Berry: the override is in the right field of the committed package.json; plugin stays at ^1.0.0; the install gets allowedPeers: ['plugin'] and no also; there are no companions and verification passes; peerConflicts has declined: '2.0.0' and strategy: 'override'; the PR body names the field.
    • Override path fails verification (npm, tests fail after the upgrade): no plugin bump, 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 and allowedPeers: ['typeorm']. Each install passes, redis is 6.3.0, typeorm stays 0.3.31, outOfScope is 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).
  • Each new test fails when its branch is disabled:
    • unpackedMajor forced to false: Tests 5 failed (5) in peer-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.
  • Existing tests updated, because they pinned the old escalation:
    • In peer-preflight.test.ts, the "no pack covers the major" case expected plan.also: ['plugin'] and migration: 'own'; it is removed, and the new tests cover that behavior.
    • The --no-llm stop and the UPT-102 "first accepting release" tests now use a packed blocker (express 4 → 5), which keeps their intent.
    • The --allow-peer test now also expects strategy and override in peerConflicts.

Local:

pnpm test       uptide:test        Tests  538 passed (538)
                @uptide/core:test  Tests  2427 passed | 37 skipped (2464)
                Tasks: 4 successful, 4 total
pnpm typecheck  Tasks: 3 successful, 3 total
pnpm lint       Checked 943 files. No fixes applied. Found 2 warnings. Found 1 info.   (same as main)
public:check    clean

Open questions

  • pnpm field: the ticket names pnpm.overrides. This PR keeps what --allow-peer already writes for pnpm, pnpm.peerDependencyRules.allowedVersions. That is pnpm's setting for accepting a peer range, while pnpm.overrides replaces the version a dependency resolves to. The real pnpm 10.17.1 install above passes with it. Switch to pnpm.overrides if you prefer.
  • No other strategy after a failed override: when verification fails with the override, the run reports blocked. It does not try anything else; the only other strategy today would be the unpacked major, which is now refused by default.
  • Changed default for unpacked majors: they now get an override without --allow-peer, for every manager, including pnpm and Yarn, which before only warned. Same-major and packed-major blockers behave as before.
  • Cosmetic: the terminal's "Moved with the upgraded packages" still shows typeorm 0.3.31 → 0.3.31 for the allowed peer's moved subtree. That line already appeared this way for --allow-peer runs and is not changed here.

🤖 Generated with Claude Code

…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>
@tenki-reviewer

tenki-reviewer Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 blocked strategy blames any check failure on the override (run.ts:1430) — run.ts sets strategy: 'blocked' whenever g.declined && !(checksPassed && !broken) (packages/core/src/fix/run.ts:1432), but checksPassed aggregates every verification signal — tests, lint, clean install, pending git status, type coverage — and broken includes parentUnchecked, where the parent build could not be checked at all.

The change introduces a decline/override loop for unresolvable peer majors in peer-preflight.ts, threads the results through run.ts and report.ts, and renders them in the CLI via format-fix.ts and feedback.ts. Package-manager-specific override writing is exercised by new tests in peer-override.test.ts and compatible-ranges.test.ts.

Files Change
packages/core/src/fix/peer-preflight.ts Adds declined-major tracking, advice selection, and the decline/re-resolve loop for peer conflicts.
packages/core/src/fix/run.ts, packages/core/src/fix/report.ts Persists strategy, declined, and override fields into the fix run and report.json.
packages/cli/src/format-fix.ts, packages/core/src/fix/feedback.ts Renders override/decline outcomes in terminal and PR feedback output.
packages/core/src/fix/peer-override.test.ts, packages/core/src/fix/peer-preflight.test.ts, packages/core/src/fix/managers/compatible-ranges.test.ts New and updated tests covering override writing, decline loop, and range handling.
.changeset/peer-override-not-unpacked-major.md Changeset documenting the peer-override behavior fix.

Reviewed commit: 30b580b

@tenki-reviewer tenki-reviewer Bot 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.

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

: 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`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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).

@lucasbraum
lucasbraum merged commit a5e3f57 into main Oct 20, 2026
29 checks passed
@lucasbraum
lucasbraum deleted the lucasbraum/upt-125-peer-dependency-blocker-escalates-to-an-unpacked-major branch October 20, 2026 07:50
@uptide-release uptide-release Bot mentioned this pull request Oct 20, 2026
lucasbraum pushed a commit that referenced this pull request Oct 20, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant