diff --git a/.changeset/eresolve-staged-retry.md b/.changeset/eresolve-staged-retry.md new file mode 100644 index 0000000..248f852 --- /dev/null +++ b/.changeset/eresolve-staged-retry.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +`fix` on npm no longer stops when the lock-only install hits ERESOLVE from the old lockfile. It retries without the lockfile entries of the moved packages, their companions and the packages whose peers they satisfy, and only then with the whole lockfile re-resolved; the report says which step worked, and the lockfile check reports the churn as usual. It never uses `--legacy-peer-deps` or `--force`. When every step fails, `fix` ends blocked, naming the conflicting packages from npm's ERESOLVE report. `fix --json` now prints JSON on every exit, a failure included; when the run already has its report (a PR body over GitHub's size limit), that report comes with the error. diff --git a/fixtures/lockfiles/eresolve/npm-11.txt b/fixtures/lockfiles/eresolve/npm-11.txt new file mode 100644 index 0000000..543cc70 --- /dev/null +++ b/fixtures/lockfiles/eresolve/npm-11.txt @@ -0,0 +1,22 @@ +npm error code ERESOLVE +npm error ERESOLVE could not resolve +npm error +npm error While resolving: app@1.0.0 +npm error Found: toggle@1.0.0 +npm error node_modules/toggle +npm error toggle@"^1.0.0" from the root project +npm error +npm error Could not resolve dependency: +npm error toggle@"^1.0.0" from the root project +npm error +npm error Conflicting peer dependency: ui@2.0.0 +npm error node_modules/ui +npm error peer ui@"^1.0.0" from toggle@1.0.0 +npm error node_modules/toggle +npm error toggle@"^1.0.0" from the root project +npm error +npm error Fix the upstream dependency conflict, or retry this command with --force or --legacy-peer-deps to accept an incorrect (and potentially broken) dependency resolution. +npm error +npm error +npm error For a full report see: +npm error /home/user/.npm/_logs/2026-01-01T00_00_00_000Z-eresolve-report.txt diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 125faef..0779f0f 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1,7 +1,14 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { type FixReport, PeerPreflightError, PublishError, UptideError } from '@uptide/core'; +import { + type FixReport, + InstallBlockedError, + PeerPreflightError, + PublishError, + ReportedError, + UptideError, +} from '@uptide/core'; import { describe, expect, it } from 'vitest'; import { run } from './cli.js'; import { @@ -501,6 +508,47 @@ describe('uptide fix', () => { expect(io.stderr()).not.toMatch(/✖|LLM spend:/); }); + it.each([ + [ + 'blocked install', + new InstallBlockedError({ + attempts: ['lock-only', 'moved entries', 'whole lockfile'], + conflicts: ['Found: toggle@1.0.0', 'Conflicting peer dependency: ui@2.0.0'], + }), + { + blocked: { + attempts: ['lock-only', 'moved entries', 'whole lockfile'], + conflicts: ['Found: toggle@1.0.0', 'Conflicting peer dependency: ui@2.0.0'], + }, + }, + ], + ['any other failure', new UptideError('INSTALL_FAILED', 'npm ci failed'), {}], + [ + 'a run whose report exists', + new ReportedError('PR_BODY_TOO_LARGE', 'body too large', { + branch: 'uptide/zod-4', + llm: { costUsd: 0.42 }, + }), + { branch: 'uptide/zod-4', llm: { costUsd: 0.42 } }, + ], + ])('writes the JSON report on exit 2 too: %s', async (_case, error, extra) => { + const engine = fakeEngine({ + fix: async () => { + throw error; + }, + }); + const io = memoryIo({ cwd: pnpmGitRepo() }); + expect(await run(['fix', 'zod', '--no-llm', '--json'], io, engine)).toBe(2); + expect(JSON.parse(io.stdout())).toEqual({ + error: { code: error.code, message: error.message }, + ...extra, + }); + // Text names the same packages. + const plain = memoryIo({ cwd: pnpmGitRepo() }); + expect(await run(['fix', 'zod', '--no-llm'], plain, engine)).toBe(2); + expect(plain.stderr()).toContain(error.message); + }); + it.each([false, true])( 'reports unavailable spend only after an LLM attempt (%s)', async (attempted) => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 83b887e..491d304 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,7 @@ import { activePacks, errorCode, formatFix, + InstallBlockedError, isFailure, leadName, leadNames, @@ -15,6 +16,7 @@ import { packLeading, priceFor, publicationFailureReason, + ReportedError, registeredPacks, scaffoldPack, selectLlm, @@ -187,7 +189,7 @@ export async function run( const act = async ( flags: Shared, body: (ctx: { ui: Ui; progress: Progress; cwd: string }) => Promise, - options: { quiet?: boolean } = {}, + options: { quiet?: boolean; jsonErrors?: boolean } = {}, ): Promise => { const ui = uiOf(io, flags); if (['list', 'check'].includes(argv[0] ?? '') && (!io.outTty || !ui.color)) { @@ -214,6 +216,13 @@ export async function run( } else { if (err instanceof CliError && err.options.next) err.options.next = suggest(err.options.next); + // `fix --json` answers on every exit, a failure included (UPT-107). + if (flags.json && options.jsonErrors) + emit({ + ...(err instanceof ReportedError ? err.report : {}), + error: { code: errorCode(err), message: explain(err) }, + ...(err instanceof InstallBlockedError ? { blocked: err.blocked } : {}), + }); io.err(renderError(err, ui.color)); } code = @@ -1207,7 +1216,7 @@ ${runnable(PRIVACY)}`, } return report.verification.passed ? EXIT.ok : EXIT.breaking; }, - { quiet: !flags.verbose && !flags.debug }, + { quiet: !flags.verbose && !flags.debug, jsonErrors: true }, ), ); diff --git a/packages/cli/src/engine-worker.ts b/packages/cli/src/engine-worker.ts index 9eb085a..79fc507 100644 --- a/packages/cli/src/engine-worker.ts +++ b/packages/cli/src/engine-worker.ts @@ -1,5 +1,11 @@ import { parentPort, workerData } from 'node:worker_threads'; -import { errorCode, PeerPreflightError, watchInterruptibleClones } from '@uptide/core'; +import { + errorCode, + InstallBlockedError, + PeerPreflightError, + ReportedError, + watchInterruptibleClones, +} from '@uptide/core'; import { explain } from './errors.js'; import { type Job, runJob } from './jobs.js'; @@ -22,5 +28,7 @@ runJob(workerData as Job, (event) => parentPort?.postMessage({ type: 'progress', ...(err instanceof PeerPreflightError ? { peerConflicts: err.peerConflicts, next: err.next } : {}), + ...(err instanceof InstallBlockedError ? { blocked: err.blocked } : {}), + ...(err instanceof ReportedError ? { report: err.report } : {}), }), ); diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index c896de8..98629d1 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -7,6 +7,7 @@ import { type ErrorCode, type FixReport, fixPreflight, + InstallBlockedError, type ListOptions, type ListReport, listDependencies, @@ -19,6 +20,7 @@ import { type ProgressListener, type PublishTarget, publishTarget, + ReportedError, removeOnInterrupt, removeRun, typescriptAdapter, @@ -122,6 +124,8 @@ export function inWorker( code: ErrorCode; peerConflicts?: PeerBlockerGroup[]; next?: string; + blocked?: InstallBlockedError['blocked']; + report?: object; }, ) => { if ('type' in reply) { @@ -141,7 +145,11 @@ export function inWorker( reject( reply.peerConflicts && reply.next ? new PeerPreflightError(reply.peerConflicts, reply.next, reply.message) - : new UptideError(reply.code, reply.message), + : reply.blocked + ? new InstallBlockedError(reply.blocked) + : reply.report + ? new ReportedError(reply.code, reply.message, reply.report) + : new UptideError(reply.code, reply.message), ); }, ); diff --git a/packages/cli/src/format-fix.test.ts b/packages/cli/src/format-fix.test.ts index 4e38d1b..2cd319b 100644 --- a/packages/cli/src/format-fix.test.ts +++ b/packages/cli/src/format-fix.test.ts @@ -155,6 +155,22 @@ it('lists packages re-resolved because a peer changed', () => { expect(out).toContain('plugin 1.1.0 → 1.2.0 · peers react'); }); +it('says which ERESOLVE retry resolved the lockfile', () => { + const report = fixReport(true); + report.lockfile = { + manager: 'npm', + file: 'package-lock.json', + added: [], + removed: [], + changed: [], + allowed: [], + eresolveRetry: { step: 'moved entries', dropped: ['toggle', 'ui'], conflicts: [] }, + }; + expect(formatFixSummary(report, { color: false })).toContain( + "Lockfile re-resolved\n npm's lock-only install hit ERESOLVE from the old lockfile; it resolved without the lockfile entries of toggle, ui\n", + ); +}); + it('lists transitive changes under the package that moved them, two at a time', () => { const report = fixReport(true); report.lockfile = { diff --git a/packages/cli/src/format-fix.ts b/packages/cli/src/format-fix.ts index a82eba5..614ddab 100644 --- a/packages/cli/src/format-fix.ts +++ b/packages/cli/src/format-fix.ts @@ -3,6 +3,7 @@ import { baselineHeadline, baselineHealthOf, costStopMessage, + describeEresolveRetry, describeOutOfScope, describePhases, describeTransitive, @@ -121,6 +122,13 @@ export function formatFixSummary(report: FixReport, opts: FormatFixOptions = {}) for (const c of report.lockfile.parentChurn) lines.push(` ${describeOutOfScope(c)}`); lines.push(''); } + if (report.lockfile?.eresolveRetry) { + lines.push( + colors.bold('Lockfile re-resolved'), + ` ${describeEresolveRetry(report.lockfile.eresolveRetry)}`, + '', + ); + } if (report.lockfile?.peerReresolved?.length) { lines.push(colors.bold('Re-resolved because a peer changed')); for (const p of report.lockfile.peerReresolved) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 16750a5..28d1a99 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -93,6 +93,8 @@ export type ErrorCode = | 'NO_PROJECT' | 'TYPESCRIPT_UNAVAILABLE' | 'INSTALL_FAILED' + | 'INSTALL_BLOCKED' + | 'PR_BODY_TOO_LARGE' | 'INCONSISTENT_UPGRADE' | 'PACKAGE_MANAGER_UNAVAILABLE' | 'PACKAGE_MANAGER_VERSION' @@ -131,6 +133,19 @@ export class UptideError extends Error { super(message, options); } } +/** + * A failure after the run wrote its report (`report.json`): `fix --json` prints that report + * with the error, so a failed exit still says what ran and what it cost (UPT-107). + */ +export class ReportedError extends UptideError { + constructor( + code: ErrorCode, + message: string, + readonly report: object, + ) { + super(code, message); + } +} /** * A refusal no agent continues past (UPT-78): downgrades, secrets, private data. Fallbacks * check this type, never where the error was thrown. diff --git a/packages/core/src/fix/managers/eresolve.test.ts b/packages/core/src/fix/managers/eresolve.test.ts new file mode 100644 index 0000000..6b4d563 --- /dev/null +++ b/packages/core/src/fix/managers/eresolve.test.ts @@ -0,0 +1,162 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { maxSatisfying, satisfies } from '../../fetch/range.js'; +import * as process from '../process.js'; +import { InstallBlockedError, upgradeInstall } from './upgrade.js'; + +// UPT-107: npm's lock-only install ERESOLVEs from the old lockfile although the same manifest +// resolves fresh. A stand-in npm: it resolves from a small registry, keeps what the lockfile +// already locks, and fails with npm's own ERESOLVE report while `conflict` holds. +const eresolve = readFileSync( + resolve(import.meta.dirname, '../../../../../fixtures/lockfiles/eresolve/npm-11.txt'), + 'utf8', +); +type Entry = { version: string; peerDependencies?: Record }; +type Lock = { packages: Record }; +const registry: Record = { + ui: [{ version: '1.0.0' }, { version: '2.0.0' }], + 'ui-dom': [ + { version: '1.0.0', peerDependencies: { ui: '^1.0.0' } }, + { version: '2.0.0', peerDependencies: { ui: '^2.0.0' } }, + ], + toggle: [ + { version: '1.0.0', peerDependencies: { ui: '^1.0.0' } }, + { version: '1.1.0', peerDependencies: { ui: '^1.0.0 || ^2.0.0' } }, + ], + unrelated: [{ version: '1.0.0' }, { version: '1.2.0' }], +}; +const lockOf = (deps: Record, entries: Record) => ({ + name: 'app', + lockfileVersion: 3, + requires: true, + packages: { + '': { name: 'app', dependencies: deps }, + ...Object.fromEntries(Object.entries(entries).map(([n, e]) => [`node_modules/${n}`, e])), + }, +}); +const ranges = { ui: '^1.0.0', 'ui-dom': '^1.0.0', toggle: '^1.0.0', unrelated: '^1.0.0' }; +const first = (name: string) => registry[name]?.[0] as Entry; + +let root = ''; +afterEach(() => { + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); +}); + +function repo(conflict: (lock: Lock) => boolean) { + root = mkdtempSync(join(tmpdir(), 'uptide-eresolve-')); + const manifest = (deps: Record) => + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'app', dependencies: deps })); + manifest(ranges); + const before = Object.fromEntries(Object.keys(ranges).map((n) => [n, first(n)])); + writeFileSync(join(root, 'package-lock.json'), JSON.stringify(lockOf(ranges, before))); + for (const args of [ + ['init', '-q'], + ['add', '.'], + ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'parent'], + ]) + execFileSync('git', args, { cwd: root }); + // What the plan writes: the lead and its companion bumped. + manifest({ ...ranges, ui: '^2.0.0', 'ui-dom': '^2.0.0' }); + const installs: { args: string[]; lock: Lock }[] = []; + vi.spyOn(process, 'command').mockImplementation(async (_root, _bin, args) => { + if (args[0] === '--version') return { code: 0, output: '11.6.2', timeout: false }; + if (args[0] !== 'install') return { code: 0, output: '', timeout: false }; + const lock: Lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')); + installs.push({ args, lock }); + if (conflict(lock)) return { code: 1, output: eresolve, timeout: false }; + const deps: Record = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ).dependencies; + const entries: Record = {}; + for (const [name, range] of Object.entries(deps)) { + const locked = lock.packages[`node_modules/${name}`]; + const versions = (registry[name] ?? []).map((e) => e.version); + const version = + locked && satisfies(locked.version, range) + ? locked.version + : (maxSatisfying(versions, range) as string); + entries[name] = registry[name]?.find((e) => e.version === version) as Entry; + } + writeFileSync(join(root, 'package-lock.json'), JSON.stringify(lockOf(deps, entries))); + return { code: 0, output: '', timeout: false }; + }); + return installs; +} +const upgrade = { + name: 'ui', + version: '2.0.0', + also: [{ name: 'ui-dom', version: '2.0.0' }], + files: ['package.json'], + workspaces: ['.'], + acceptOutOfScope: true, +}; +const locked = (lock: Lock | undefined) => + Object.keys(lock?.packages ?? {}) + .filter(Boolean) + .map((k) => k.replace('node_modules/', '')); +const neverForced = (installs: { args: string[] }[]) => { + for (const { args } of installs) { + expect(args).not.toContain('--legacy-peer-deps'); + expect(args).not.toContain('--force'); + } +}; + +it('retries without the moved packages and the packages whose peers they satisfy', async () => { + // toggle 1.0.0 locks a peer the upgrade leaves: only dropping it lets npm resolve 1.1.0. + const installs = repo((lock) => lock.packages['node_modules/toggle']?.version === '1.0.0'); + const report = await upgradeInstall(root, upgrade); + expect(report.eresolveRetry).toMatchObject({ + step: 'moved entries', + dropped: ['toggle', 'ui', 'ui-dom'], + }); + expect(report.eresolveRetry?.conflicts).toContain('Conflicting peer dependency: ui@2.0.0'); + // The retry started from the old lockfile less those entries: unrelated stayed locked. + expect(locked(installs[1]?.lock)).toEqual(['unrelated']); + const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')); + expect(lock.packages['node_modules/toggle'].version).toBe('1.1.0'); + expect(lock.packages['node_modules/unrelated'].version).toBe('1.0.0'); + neverForced(installs); +}); + +it('re-resolves the whole lockfile only when dropping the moved entries is not enough', async () => { + // A conflict outside the upgrade's names: the old unrelated entry keeps npm from resolving. + const installs = repo((lock) => lock.packages['node_modules/unrelated']?.version === '1.0.0'); + const report = await upgradeInstall(root, upgrade); + expect(report.eresolveRetry?.step).toBe('whole lockfile'); + expect(locked(installs[1]?.lock)).toEqual(['unrelated']); + expect(locked(installs[2]?.lock)).toEqual([]); + // A fresh resolve's churn (unrelated 1.0.0 → 1.2.0) is the same at the parent commit: + // reported as such by the lock guard, not refused and not blamed on the upgrade. + expect(report.parentChurn).toEqual( + expect.arrayContaining([ + { name: 'unrelated', from: '1.0.0', to: '1.2.0' }, + { name: 'toggle', from: '1.0.0', to: '1.1.0' }, + ]), + ); + expect(report.outOfScope).toBeUndefined(); + neverForced(installs); +}); + +it('ends blocked, naming the packages npm reported, when every step fails', async () => { + const installs = repo(() => true); + const error = await upgradeInstall(root, upgrade).catch((e: unknown) => e); + expect(error).toBeInstanceOf(InstallBlockedError); + const blocked = error as InstallBlockedError; + expect(blocked.code).toBe('INSTALL_BLOCKED'); + expect(blocked.blocked.attempts).toEqual(['lock-only', 'moved entries', 'whole lockfile']); + expect(blocked.blocked.conflicts).toEqual([ + 'While resolving: app@1.0.0', + 'Found: toggle@1.0.0', + 'Could not resolve dependency: toggle@"^1.0.0" from the root project', + 'Conflicting peer dependency: ui@2.0.0', + 'peer ui@"^1.0.0" from toggle@1.0.0', + ]); + expect(blocked.message).toContain('Conflicting peer dependency: ui@2.0.0'); + expect(blocked.message).toContain('--legacy-peer-deps'); + expect(installs).toHaveLength(3); + neverForced(installs); +}); diff --git a/packages/core/src/fix/managers/upgrade.ts b/packages/core/src/fix/managers/upgrade.ts index 2490774..b559a72 100644 --- a/packages/core/src/fix/managers/upgrade.ts +++ b/packages/core/src/fix/managers/upgrade.ts @@ -38,6 +38,109 @@ const INSTALLED = ['dependencies', 'devDependencies', 'optionalDependencies'] as export interface InstallReport extends LockDiff { manager: string; file: string; + /** npm's lock-only install ERESOLVEd from the old lockfile; the smaller retry that resolved (UPT-107). */ + eresolveRetry?: EresolveRetry; +} +type RetryStep = 'moved entries' | 'whole lockfile'; +export interface EresolveRetry { + step: RetryStep; + /** Packages whose lockfile entries were dropped; empty for the whole lockfile. */ + dropped: string[]; + /** What npm reported on the first attempt. */ + conflicts: string[]; +} +export const describeEresolveRetry = (r: EresolveRetry): string => + `npm's lock-only install hit ERESOLVE from the old lockfile; it resolved ${r.step === 'whole lockfile' ? 'with the whole lockfile re-resolved' : `without the lockfile entries of ${r.dropped.join(', ')}`}`; +/** Every retry ERESOLVEd too; nothing was forced (`--legacy-peer-deps`, `--force`). */ +export class InstallBlockedError extends UptideError { + constructor(readonly blocked: { attempts: string[]; conflicts: string[] }) { + super( + 'INSTALL_BLOCKED', + `npm could not resolve the upgrade (ERESOLVE): lock-only from the old lockfile, again without the lockfile entries of the moved packages and the packages whose peers they satisfy, and again with the whole lockfile re-resolved. npm reported:\n${blocked.conflicts.map((c) => ` ${c}`).join('\n')}\nUptide never retries with --legacy-peer-deps or --force. Nothing was changed.`, + ); + } +} +/** The lines of npm's ERESOLVE report that name packages, in its own words. */ +export function eresolveConflicts(output: string): string[] { + const lines = output.split('\n').map((l) => l.replace(/^npm (?:error|ERR!) ?/, '').trim()); + const named = lines.flatMap((line, i) => + /^(While resolving|Found|Conflicting peer dependency): /.test(line) || + /^peer \S+ from /.test(line) + ? [line] + : line === 'Could not resolve dependency:' && lines[i + 1] + ? [`${line} ${lines[i + 1]}`] + : [], + ); + return [...new Set(named)]; +} +const isEresolve = (error: unknown) => + error instanceof UptideError && + error.code === 'INSTALL_FAILED' && + /\bERESOLVE\b/.test(error.message); +/** + * The old lockfile without the entries of `names` and everything installed below them, or + * without every installed entry (undefined): npm resolves those afresh. Links stay. + */ +function dropEntries(text: string, names?: Set): string { + const json = JSON.parse(text); + for (const [key, entry] of Object.entries<{ link?: boolean }>(json.packages ?? {})) { + const installed = key.split('node_modules/').slice(1); + if (!installed.length || entry.link) continue; + if (!names || installed.some((n) => names.has(n.replace(/\/$/, '')))) delete json.packages[key]; + } + return `${JSON.stringify(json, null, 2)}\n`; +} +/** The moved packages and every locked package that declares one of them as a peer. */ +function peerHolders(text: string, moved: string[]): Set { + const names = new Set(moved); + const packages = JSON.parse(text).packages ?? {}; + for (const [key, entry] of Object.entries<{ peerDependencies?: Record }>( + packages, + )) + if (key.includes('node_modules/') && moved.some((m) => entry.peerDependencies?.[m])) + names.add(key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length)); + return names; +} +/** + * `attempt` from the old lockfile, then (npm ERESOLVE only) from smaller ones, smallest + * re-resolve first (UPT-107). Returns the lockfile the successful attempt started from. + */ +async function resolving( + pm: PackageManager, + lockfile: string, + beforeText: string, + moved: string[], + attempt: () => Promise, +): Promise<{ start: string; retry?: EresolveRetry }> { + let failure: unknown; + try { + await attempt(); + return { start: beforeText }; + } catch (error) { + if (pm.kind !== 'npm' || !isEresolve(error)) throw error; + failure = error; + } + const conflicts = eresolveConflicts((failure as Error).message); + const holders = peerHolders(beforeText, moved); + const steps: [RetryStep, Set | undefined][] = [ + ['moved entries', holders], + ['whole lockfile', undefined], + ]; + for (const [step, names] of steps) { + const start = dropEntries(beforeText, names); + writeFileSync(lockfile, start); + try { + await attempt(); + return { start, retry: { step, dropped: names ? [...names].sort() : [], conflicts } }; + } catch (error) { + if (!isEresolve(error)) throw error; + failure = error; + } + } + throw new InstallBlockedError({ + attempts: ['lock-only', ...steps.map(([step]) => step)], + conflicts: eresolveConflicts((failure as Error).message), + }); } export function lockGraph(pm: PackageManager, text: string, target: Targets): LockGraph { return pm.kind === 'npm' @@ -134,6 +237,8 @@ async function parentLock( names: string[], lockfile: string, beforeText: string, + /** The lockfile the upgrade's install started from: `beforeText` less any dropped entries. */ + startText: string, ): Promise { const current = new Map( files @@ -152,7 +257,7 @@ async function parentLock( if (text === undefined) rmSync(join(root, file)); else writeFileSync(join(root, file), `${text}\n`); } - writeFileSync(lockfile, beforeText); + writeFileSync(lockfile, startText); const parse = pm.kind === 'npm' ? parseNpm : pm.kind === 'pnpm' ? parsePnpm : parseYarn; const locked = new Map>(); for (const workspace of workspaces) { @@ -191,39 +296,41 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise version.get(name)); - if (pm.kind === 'yarn-berry') { - // Restoring a broad range drops Yarn's temporary resolution; pin its descriptor in the - // lockfile instead. A range the plan rewrote (`^14.0.0` → `^16.1.0`) is as broad as one it - // kept: left alone, Yarn resolves its newest match (16.3.3), not the planned version. - const descriptors = new Map(); - for (const [, text] of originals) { - const json = JSON.parse(text); - for (const section of INSTALLED) - for (const move of moves) { - const spec = json[section]?.[move.name]; - if (!spec) continue; - const alias = /^(npm:(?:@[^/\s]+\/)?[^@\s]+@)(.+)$/.exec(spec); - const range = alias?.[2] ?? spec; - // Exact self-resolutions make Yarn's resolver recurse into itself. - if (range === move.version || !satisfies(move.version, range)) continue; - descriptors.set( - `${move.name}@${spec.startsWith('npm:') ? spec : `npm:${spec}`}`, - `${alias?.[1] ?? 'npm:'}${move.version}`, - ); - } + const { start, retry } = await resolving(pm, lockfile, beforeText, names, async () => { + await installPinned(root, pm, upgrade.workspaces, names, (_, name) => version.get(name)); + if (pm.kind === 'yarn-berry') { + // Restoring a broad range drops Yarn's temporary resolution; pin its descriptor in the + // lockfile instead. A range the plan rewrote (`^14.0.0` → `^16.1.0`) is as broad as one it + // kept: left alone, Yarn resolves its newest match (16.3.3), not the planned version. + const descriptors = new Map(); + for (const [, text] of originals) { + const json = JSON.parse(text); + for (const section of INSTALLED) + for (const move of moves) { + const spec = json[section]?.[move.name]; + if (!spec) continue; + const alias = /^(npm:(?:@[^/\s]+\/)?[^@\s]+@)(.+)$/.exec(spec); + const range = alias?.[2] ?? spec; + // Exact self-resolutions make Yarn's resolver recurse into itself. + if (range === move.version || !satisfies(move.version, range)) continue; + descriptors.set( + `${move.name}@${spec.startsWith('npm:') ? spec : `npm:${spec}`}`, + `${alias?.[1] ?? 'npm:'}${move.version}`, + ); + } + } + for (const [descriptor, resolution] of descriptors) + await run(root, pm, [ + ...(pm.via === 'corepack' ? ['yarn'] : []), + 'set', + 'resolution', + descriptor, + resolution, + ]); } - for (const [descriptor, resolution] of descriptors) - await run(root, pm, [ - ...(pm.via === 'corepack' ? ['yarn'] : []), - 'set', - 'resolution', - descriptor, - resolution, - ]); - } - // Let the real manager reconcile the final manifest, including its original range operator. - await reconcile(root, pm, upgrade.workspaces); + // Let the real manager reconcile the final manifest, including its original range operator. + await reconcile(root, pm, upgrade.workspaces); + }); const written = readFileSync(lockfile, 'utf8'); const afterText = pm.kind === 'npm' && Number(pm.actualVersion?.split('.')[0]) < 11 @@ -251,7 +358,7 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise