Skip to content

fix(install): resolve the exact target on Yarn classic and accept a same-major patch it cannot pin (UPT-124) - #264

Merged
lucasbraum merged 2 commits into
mainfrom
lucasbraum/upt-124-target-version-is-not-what-the-package-manager-resolves-yarn
Oct 20, 2026
Merged

lucasbraum merged 2 commits into
mainfrom
lucasbraum/upt-124-target-version-is-not-what-the-package-manager-resolves-yarn

Conversation

@lucasbraum

Copy link
Copy Markdown
Collaborator

Fixes UPT-124.

Root cause

The fatal check is packages/core/src/fix/managers/upgrade.ts:479 on main: Requested …, but … resolved …; verification cannot use a different target. It also has a second copy after the install, assertInstalledVersions in fix/versions.ts:56.

The check was right; the pin before it failed on Yarn classic. installPinned pins Yarn through root resolutions, but Yarn classic applies resolutions to transitive dependencies only. A direct "ai": "^7.0.105" therefore resolves its newest match. I reproduced this by hand on stakwork/strut:

  • with resolutions: { ai: "7.0.105" } the lockfile gets ai@7.0.105 → 7.0.105 and ai@^7.0.105 → 7.0.146;
  • pinning the manifest to 7.0.105 and then restoring ^7.0.105 resolves 7.0.146 again;
  • adding ai@^7.0.105 to the ai@7.0.105 entry's header holds through yarn install and yarn install --frozen-lockfile.

Berry already pins through yarn set resolution. npm and pnpm pin the manifest and then reuse the satisfying entry. The network-gated matrix in compatible-ranges.test.ts covers npm, pnpm and Berry 3/4, but had no Yarn classic case.

What changed

  • Yarn classic pins the target: pinClassicPatterns moves each range's pattern (ai@^7.0.105) onto the lockfile entry at the target and drops the entry that is left empty. It runs before the reconcile, in the same place Berry runs set resolution. The manifest keeps its range (^7.0.105 stays caret). The LOCKFILE_OUT_OF_SCOPE guard still runs on the final lockfile, unchanged.
  • Acceptance rule (upgradeInstall): if the lockfile still resolves another version, the run continues only if that version is in the target's major, satisfies the manifest range (npm aliases unwrapped), and is the same in every workspace. In run.ts, pack.supports(from, resolved) must also hold. Anything else stops with Requested <name>@<target>, but <workspace> resolved <version>: <reason>. The reason is one of: a different major than …, outside the manifest range …, another workspace resolved …, nothing is installed, or outside what the <pack> pack covers.
  • Reported: lockfile.resolved: [{ name, requested, version }] on the install report, and resolvedVersion next to target in report.json (FixResult, typed in fix/types.ts). The PR body and the terminal summary get a Resolved version differs from the target section. Both assertInstalledVersions calls in run.ts, and the one in reverify.ts, use the resolved versions. When target and resolved already match, nothing changes: no field and no section.
  • Not touched: peer-blocker handling (UPT-125), bench/, pricing.ts, release scripts, the Action pin, golden workflows.

Repro: stakwork/strut at 41fb967 (parent of PR 11's merge eda56a5), yarn 1.22.22

Command, from the read-only clone (nothing pushed, no comments on that repo):

ANTHROPIC_API_KEY=invalid-placeholder uptide fix ai --target 7.0.105 --verbose --ci --keep-temp --max-cost 0.001

The key is a placeholder and the budget is $0.001, so no billed LLM call was possible.

Before (origin/main 5ffd256), exit 2, about 30 s, fails at install:

  install · ai 7.0.105 with 1 package (4.6s)
✖ Migration of ai (check, upgrade, fixes, verification) (30s)
error: Requested ai@7.0.105, but . resolved 7.0.146; verification cannot use a different target.
exit=2

After (this branch): install and resolution pass, and the run goes on through rules, assist and verification. It costs $0.00 and makes 0 agent edits. Verification then fails on strut's own tests, as expected, because no assisted fix ran:

  install · ai 7.0.105 with 1 package (5.3s)
  …
✔ Migration of ai (check, upgrade, fixes, verification)  verification failed (40s)
uptide fix · ai 6.0.235 → 7.0.105 (requested) · verification failed · 40s
  Feedback  0/3 rounds · $0.00 of $0.00
exit=1

Lockfile on the produced branch, with the manifest range kept:

$ git show uptide/ai-7.0.105:yarn.lock | grep -A1 '^ai@'
ai@^7.0.105:
  version "7.0.105"
$ git show uptide/ai-7.0.105:package.json | grep '"ai"'
    "ai": "^7.0.105",

There is no resolvedVersion in that run's report.json, because target and resolved now match.

Tests

  • fix/managers/resolved-version.test.ts (new), mocked installs for npm, pnpm and Yarn classic:
    • (a) the exact target gives no resolved note;
    • (b) a newer patch in the same major and range continues, with resolved: [{ name, requested: '1.1.0', version: '1.1.3' }];
    • (c) a different major stops with Requested target@1.1.0, but . resolved 2.0.0: a different major than 1.1.0;
    • outside the manifest range (~1.1.0 → 1.2.0) stops too.
    • It also has a unit test of pinClassicPatterns: the moved pattern, a shared header, and no entry at the target.
  • compatible-ranges.test.ts (UPTIDE_NETWORK=1, real registry): new Yarn classic case (a). React ^19.0.0 is locked at 19.1.0, the upgrade goes to 19.2.0, the lockfile and node_modules end at 19.2.0, the range stays ^19.0.0, and a frozen install leaves the lockfile unchanged. With the pattern merge disabled, this case fails. The whole matrix passes: Tests 14 passed (14) (npm, pnpm, Berry 3/4, Yarn classic).
  • PR body (report.test.ts) and terminal (format-fix.test.ts) each get a test for the new section.

Local, on the final commit:

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

Not verified / open

  • Case (b) is not observed end to end: npm, pnpm and Yarn all resolve the exact target in the cases tested, so (b) and (c) are covered by mocked installs only. The pack-range refusal in run.ts (outside what the <pack> pack covers) has no test of its own.
  • uptide.run/v1: "report.json" here means the fix result (FixResult), which has no JSON schema file; its schema is the TypeScript type. The Action's uptide.run/v1 report (schema/uptide.run.v1.json) is unchanged and has only to. Say if resolvedVersion belongs there too.
  • Companions: they get the same major/range rule but no pack-range check (there is only one pack per run).
  • Yarn classic resolutions: still written for transitive pins, as before; only the direct pattern is moved.

🤖 Generated with Claude Code

@tenki-reviewer

tenki-reviewer Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review complete. No issues found — approved ✅.


The change threads a resolvedVersion through the fix run, reverify, report DTO, and CLI summary, and rewrites Yarn classic lockfile patterns to pin resolved versions.

Files Change
packages/core/src/fix/managers/upgrade.ts Adds classic-pattern text surgery to pin lockfile entries to the resolved version
packages/core/src/fix/run.ts, packages/core/src/fix/reverify.ts Substitutes the resolved version into installed moves and remaps old reports
packages/core/src/fix/report.ts, packages/core/src/fix/types.ts, packages/core/src/index.ts Adds resolvedVersion to the report DTO and export surface
packages/cli/src/format-fix.ts Renders the resolved-version wording in the fix summary
test files, .changeset/resolved-version-differs.md Tests for the new behavior plus a changeset

Verification found no issues at or above the reporting threshold; a few medium/low-severity edge cases (fallback gating skipping the pack-coverage check, CRLF lockfiles defeating classic pinning) remain as informational observations.

Reviewed commit: 31d3971

lucasbraum and others added 2 commits October 20, 2026 00:05
…ame-major patch it cannot pin (UPT-124)

Yarn classic applies root `resolutions` to transitive dependencies only, so a direct
`ai@^7.0.105` resolved its newest match (7.0.146) and `uptide fix` stopped with exit 2
before any LLM call. The range's lockfile pattern now moves onto the entry at the target.

When a manager still resolves another version, the run continues only if it is the same
major, inside the manifest range and covered by the pack; both versions are reported
(`resolvedVersion`, `lockfile.resolved`, PR body, terminal). Anything else stops with the
target, the resolved version and the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
… when agent-led (UPT-124)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lucas Braum <lucasbraum@gmail.com>
@lucasbraum
lucasbraum force-pushed the lucasbraum/upt-124-target-version-is-not-what-the-package-manager-resolves-yarn branch from 31d3971 to ac59ee1 Compare October 20, 2026 07:05
@lucasbraum
lucasbraum merged commit 2089d0a into main Oct 20, 2026
28 checks passed
@lucasbraum
lucasbraum deleted the lucasbraum/upt-124-target-version-is-not-what-the-package-manager-resolves-yarn branch October 20, 2026 07:16
@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