From fd3037fb5178a6372d7ae940779fbd748af4c7f8 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Sat, 17 Oct 2026 18:57:54 -0700 Subject: [PATCH 1/2] fix: retry npm ERESOLVE with the smallest re-resolve, blocked report otherwise (UPT-107) npm's lock-only install could ERESOLVE from the old lockfile although the same manifest resolves fresh (enki: react 19 with @entur/react-component-toggle), and fix stopped with exit 2 while fix --json printed nothing. On ERESOLVE, upgradeInstall now retries lock-only without the lockfile entries of the moved packages, their companions and every locked package that declares one of them as a peer; only then with the whole lockfile re-resolved. The step that worked is recorded (lockfile.eresolveRetry, shown in the summary and the PR body), the parent-commit comparison starts from the same lockfile, and the lock guard reports churn as usual. It never uses --legacy-peer-deps or --force. When every step fails, fix ends with INSTALL_BLOCKED, naming the packages from npm's ERESOLVE report. fix --json now prints JSON on every exit: { error, blocked? } on a failure. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .changeset/eresolve-staged-retry.md | 5 + fixtures/lockfiles/eresolve/npm-11.txt | 22 +++ packages/cli/src/cli.test.ts | 41 +++- packages/cli/src/cli.ts | 11 +- packages/cli/src/engine-worker.ts | 8 +- packages/cli/src/engine.ts | 6 +- packages/cli/src/format-fix.test.ts | 16 ++ packages/cli/src/format-fix.ts | 8 + packages/core/src/errors.ts | 1 + .../core/src/fix/managers/eresolve.test.ts | 162 ++++++++++++++++ packages/core/src/fix/managers/upgrade.ts | 182 ++++++++++++++---- packages/core/src/fix/report.ts | 8 + packages/core/src/index.ts | 1 + 13 files changed, 431 insertions(+), 40 deletions(-) create mode 100644 .changeset/eresolve-staged-retry.md create mode 100644 fixtures/lockfiles/eresolve/npm-11.txt create mode 100644 packages/core/src/fix/managers/eresolve.test.ts diff --git a/.changeset/eresolve-staged-retry.md b/.changeset/eresolve-staged-retry.md new file mode 100644 index 0000000..d593a13 --- /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. 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..677a04e 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1,7 +1,13 @@ 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, + UptideError, +} from '@uptide/core'; import { describe, expect, it } from 'vitest'; import { run } from './cli.js'; import { @@ -501,6 +507,39 @@ 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'), {}], + ])('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..ec4a18c 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, @@ -187,7 +188,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 +215,12 @@ 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({ + error: { code: errorCode(err), message: explain(err) }, + ...(err instanceof InstallBlockedError ? { blocked: err.blocked } : {}), + }); io.err(renderError(err, ui.color)); } code = @@ -1207,7 +1214,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..e580061 100644 --- a/packages/cli/src/engine-worker.ts +++ b/packages/cli/src/engine-worker.ts @@ -1,5 +1,10 @@ import { parentPort, workerData } from 'node:worker_threads'; -import { errorCode, PeerPreflightError, watchInterruptibleClones } from '@uptide/core'; +import { + errorCode, + InstallBlockedError, + PeerPreflightError, + watchInterruptibleClones, +} from '@uptide/core'; import { explain } from './errors.js'; import { type Job, runJob } from './jobs.js'; @@ -22,5 +27,6 @@ runJob(workerData as Job, (event) => parentPort?.postMessage({ type: 'progress', ...(err instanceof PeerPreflightError ? { peerConflicts: err.peerConflicts, next: err.next } : {}), + ...(err instanceof InstallBlockedError ? { blocked: err.blocked } : {}), }), ); diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index c896de8..6e1e673 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, @@ -122,6 +123,7 @@ export function inWorker( code: ErrorCode; peerConflicts?: PeerBlockerGroup[]; next?: string; + blocked?: InstallBlockedError['blocked']; }, ) => { if ('type' in reply) { @@ -141,7 +143,9 @@ 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) + : 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..acccb87 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -93,6 +93,7 @@ export type ErrorCode = | 'NO_PROJECT' | 'TYPESCRIPT_UNAVAILABLE' | 'INSTALL_FAILED' + | 'INSTALL_BLOCKED' | 'INCONSISTENT_UPGRADE' | 'PACKAGE_MANAGER_UNAVAILABLE' | 'PACKAGE_MANAGER_VERSION' 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 Date: Sat, 17 Oct 2026 19:13:37 -0700 Subject: [PATCH 2/2] fix: fix --json keeps the run's report when the PR body refuses (UPT-107) The enki rerun got past ERESOLVE, ran the agent, then stopped at "Required migration evidence exceeds the PR body budget". That refusal is thrown after report.json is written in the clone, and the clone is removed, so fix --json printed only the error: no cost, no lockfile, no install step. The refusal is now a ReportedError (PR_BODY_TOO_LARGE) that carries the report across the worker boundary; fix --json prints the report with the error on top. Whether the run should stop there at all is left as is. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .changeset/eresolve-staged-retry.md | 2 +- packages/cli/src/cli.test.ts | 9 +++++++++ packages/cli/src/cli.ts | 2 ++ packages/cli/src/engine-worker.ts | 2 ++ packages/cli/src/engine.ts | 6 +++++- packages/core/src/errors.ts | 14 ++++++++++++++ packages/core/src/fix/run.ts | 12 ++++++++++-- packages/core/src/index.ts | 1 + 8 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.changeset/eresolve-staged-retry.md b/.changeset/eresolve-staged-retry.md index d593a13..248f852 100644 --- a/.changeset/eresolve-staged-retry.md +++ b/.changeset/eresolve-staged-retry.md @@ -2,4 +2,4 @@ "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. +`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/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 677a04e..0779f0f 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -6,6 +6,7 @@ import { InstallBlockedError, PeerPreflightError, PublishError, + ReportedError, UptideError, } from '@uptide/core'; import { describe, expect, it } from 'vitest'; @@ -522,6 +523,14 @@ describe('uptide fix', () => { }, ], ['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 () => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ec4a18c..491d304 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -16,6 +16,7 @@ import { packLeading, priceFor, publicationFailureReason, + ReportedError, registeredPacks, scaffoldPack, selectLlm, @@ -218,6 +219,7 @@ export async function run( // `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 } : {}), }); diff --git a/packages/cli/src/engine-worker.ts b/packages/cli/src/engine-worker.ts index e580061..79fc507 100644 --- a/packages/cli/src/engine-worker.ts +++ b/packages/cli/src/engine-worker.ts @@ -3,6 +3,7 @@ import { errorCode, InstallBlockedError, PeerPreflightError, + ReportedError, watchInterruptibleClones, } from '@uptide/core'; import { explain } from './errors.js'; @@ -28,5 +29,6 @@ runJob(workerData as Job, (event) => parentPort?.postMessage({ type: 'progress', ? { 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 6e1e673..98629d1 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -20,6 +20,7 @@ import { type ProgressListener, type PublishTarget, publishTarget, + ReportedError, removeOnInterrupt, removeRun, typescriptAdapter, @@ -124,6 +125,7 @@ export function inWorker( peerConflicts?: PeerBlockerGroup[]; next?: string; blocked?: InstallBlockedError['blocked']; + report?: object; }, ) => { if ('type' in reply) { @@ -145,7 +147,9 @@ export function inWorker( ? new PeerPreflightError(reply.peerConflicts, reply.next, reply.message) : reply.blocked ? new InstallBlockedError(reply.blocked) - : new UptideError(reply.code, reply.message), + : reply.report + ? new ReportedError(reply.code, reply.message, reply.report) + : new UptideError(reply.code, reply.message), ); }, ); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index acccb87..28d1a99 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -94,6 +94,7 @@ export type ErrorCode = | 'TYPESCRIPT_UNAVAILABLE' | 'INSTALL_FAILED' | 'INSTALL_BLOCKED' + | 'PR_BODY_TOO_LARGE' | 'INCONSISTENT_UPGRADE' | 'PACKAGE_MANAGER_UNAVAILABLE' | 'PACKAGE_MANAGER_VERSION' @@ -132,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/run.ts b/packages/core/src/fix/run.ts index d8b5a89..0fd12a2 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -6,7 +6,7 @@ import { compareVersions } from '../check/version.js'; import type { PackageFetcher } from '../domain/io.js'; import { type ProgressListener, progress } from '../domain/progress.js'; import type { CheckReport, Finding } from '../domain/report.js'; -import { SafetyRefusal, UptideError } from '../errors.js'; +import { ReportedError, SafetyRefusal, UptideError } from '../errors.js'; import { createNpmFetcher } from '../fetch/npm-fetcher.js'; import { loadRegistryConfig } from '../fetch/npmrc.js'; import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js'; @@ -1218,7 +1218,15 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise