Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eresolve-staged-retry.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions fixtures/lockfiles/eresolve/npm-11.txt
Original file line number Diff line number Diff line change
@@ -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
50 changes: 49 additions & 1 deletion packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
activePacks,
errorCode,
formatFix,
InstallBlockedError,
isFailure,
leadName,
leadNames,
Expand All @@ -15,6 +16,7 @@ import {
packLeading,
priceFor,
publicationFailureReason,
ReportedError,
registeredPacks,
scaffoldPack,
selectLlm,
Expand Down Expand Up @@ -187,7 +189,7 @@ export async function run(
const act = async (
flags: Shared,
body: (ctx: { ui: Ui; progress: Progress; cwd: string }) => Promise<number>,
options: { quiet?: boolean } = {},
options: { quiet?: boolean; jsonErrors?: boolean } = {},
): Promise<void> => {
const ui = uiOf(io, flags);
if (['list', 'check'].includes(argv[0] ?? '') && (!io.outTty || !ui.color)) {
Expand All @@ -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 =
Expand Down Expand Up @@ -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 },
),
);

Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/engine-worker.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 } : {}),
}),
);
10 changes: 9 additions & 1 deletion packages/cli/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ErrorCode,
type FixReport,
fixPreflight,
InstallBlockedError,
type ListOptions,
type ListReport,
listDependencies,
Expand All @@ -19,6 +20,7 @@ import {
type ProgressListener,
type PublishTarget,
publishTarget,
ReportedError,
removeOnInterrupt,
removeRun,
typescriptAdapter,
Expand Down Expand Up @@ -122,6 +124,8 @@ export function inWorker<T>(
code: ErrorCode;
peerConflicts?: PeerBlockerGroup[];
next?: string;
blocked?: InstallBlockedError['blocked'];
report?: object;
},
) => {
if ('type' in reply) {
Expand All @@ -141,7 +145,11 @@ export function inWorker<T>(
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),
);
},
);
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/format-fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/format-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
baselineHeadline,
baselineHealthOf,
costStopMessage,
describeEresolveRetry,
describeOutOfScope,
describePhases,
describeTransitive,
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading