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/agent-led-unsafe-pins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"uptide": patch
---

With an assisting agent, a root pin Uptide cannot update safely (a nested override such as `overrides > pkg > peerDependencies > react`) no longer stops `fix` with exit 2: the run continues as an agent-led draft that leaves the pin as written and names it, with its exact path and value, at the top of the PR body. The agent-led peer reason now names each blocker's first release that accepts the target, or says none does. An agent-led draft must pass the repository's clean install (`npm ci` or the manager's frozen install) before delivery; if it does not, the PR says so at the top. Without an agent, with `--no-llm`, or on a safety refusal, `fix` still exits 2.
15 changes: 10 additions & 5 deletions docs/commands/fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,18 @@ it. An override permits dependency resolution; it does not establish runtime com
Uptide never adds `--legacy-peer-deps` or `--force`.

With an assisting agent (an API key, LLM on), these preflight refusals do not exit 2: the run
continues as an **agent-led draft**. A peer blocker runs as the suggested command above; root
pins without `--update-pins` consent are updated (`updatePins: false` in `uptide.yml` still
refuses); companions that cannot move together leave the lead alone; and resolutions the
continues as an **agent-led draft**. A peer blocker runs as the suggested command above, and
the reason names each blocker's first release that accepts the target, or says that no release
does and an override keeps it; root pins without `--update-pins` consent are updated
(`updatePins: false` in `uptide.yml` still refuses); root pins Uptide cannot update safely
(nested or selector keys, non-version values) are left as written, each named with its exact
path and value; companions that cannot move together leave the lead alone; and resolutions the
install changed outside the upgrade's subtree are kept for review instead of refused. The
reason heads the PR body and the terminal summary, with feedback rounds and spend, and the PR
opens as a draft. With `--no-llm`, without an agent, or on a downgrade, `fix` refuses as
described here.
opens as a draft. Before it is delivered, a draft must pass the repository's clean install
(`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --immutable`) in the fix clone; if it
does not, the PR body says so right under the reason and the run does not pass. With
`--no-llm`, without an agent, or on a downgrade, `fix` refuses as described here.

For pnpm and Yarn, conflicts are reported but do not stop planning. Their own settings may
still reject the install (for example strict peer checking). `--allow-peer` writes the native
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/format-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export function formatFixSummary(report: FixReport, opts: FormatFixOptions = {})
...(report.agentFallback?.length
? [` ${pad('Fallback', 9)} agent-led draft: ${report.agentFallback.join('; ')}`]
: []),
...(report.cleanInstall
? [
` ${pad('Install', 9)} ${report.cleanInstall.passed ? 'clean install passes' : 'clean install FAILS'}: ${report.cleanInstall.command}`,
]
: []),
...(report.llm.feedback
? [
` ${pad('Feedback', 9)} ${report.llm.feedback.roundsUsed}/${report.llm.feedback.maxRounds} rounds · $${report.llm.costUsd.toFixed(2)} of $${report.llm.feedback.maxCostUsd.toFixed(2)}`,
Expand Down
62 changes: 55 additions & 7 deletions packages/core/src/fix/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ import type { CheckReport, Finding } from '../domain/report.js';
import { installedManifest } from '../list/evidence.js';
import { nanos } from '../llm/pricing.js';
import { applySitePatch } from './patch.js';
import { type PeerPlan, PeerPreflightError, peerPreflight } from './peer-preflight.js';
import {
type PeerBlockerGroup,
type PeerPlan,
PeerPreflightError,
peerPreflight,
} from './peer-preflight.js';
import { git } from './process.js';
import { describeRootPin, RootPinConsentError } from './root-pins.js';
import {
describeRootPin,
describeUnsafePin,
RootPinConsentError,
UnsafeRootPinError,
} from './root-pins.js';
import type { FixOptions, FixServices } from './run.js';
import { protectedFile, type TaskRunner } from './sdk-agent.js';
import { safeFile } from './settle.js';
Expand Down Expand Up @@ -193,6 +203,18 @@ export function fallbackReport(
};
}

/** What the continued run does with one blocker: the first release that accepts the targets, or none (UPT-102). */
function blockerOutcome(p: PeerBlockerGroup, allowPeer: readonly string[]): string {
const peers = [
...new Set(p.peers.filter((r) => r.version === p.version).map((r) => `${r.peer} ${r.target}`)),
].join(', ');
if (p.newer && !allowPeer.includes(p.name))
return `${p.name} ${p.version} → ${p.newer}, the first release that accepts ${peers}`;
return p.via
? `${p.name} ${p.version} (peer of ${p.via.join(' → ')}) rejects installed ${peers}; kept with a peer override`
: `no ${p.name} release accepts ${peers}; ${p.version} kept with a peer override`;
}

/**
* Preflight refusals an assisting agent continues past (UPT-78): the run becomes an agent-led
* draft with the reason in `agentFallback`. Without an agent, it refuses as before.
Expand All @@ -212,13 +234,39 @@ export async function assistedPeerPreflight(
return {
options: next,
plan: await peerPreflight(next, services, read),
reason: `Peer blockers need a major upgrade (${error.peerConflicts.map((p) => p.name).join(', ')}); continued as \`${error.next}\``,
reason: `Peer blockers: ${error.peerConflicts.map((p) => blockerOutcome(p, error.plan.allowPeer)).join('; ')}; continued as \`${error.next}\``,
};
}
}

/** Missing pin consent, continued by an agent. An explicit `updatePins: false` still refuses. */
export function pinConsentFallback(error: unknown, assist: boolean): string {
if (!assist || !(error instanceof RootPinConsentError) || error.refused) throw error;
return `Root pins updated without --update-pins consent: ${error.pins.map(describeRootPin).join(' ')}`;
/**
* Root pins an agent continues past: missing consent (UPT-78) updates them; pins Uptide cannot
* update safely are left as written and named, exactly, for the reviewer (UPT-102). An explicit
* `updatePins: false` still refuses, and so does every case without an agent.
*/
export function assistedRootPins<T extends { updatePins?: boolean; unsafePins?: 'leave' }>(
options: T,
plan: (options: T) => unknown,
assist: boolean,
): { options: T; reasons: string[] } {
const reasons: string[] = [];
for (;;) {
try {
plan(options);
return { options, reasons };
} catch (error) {
if (!assist) throw error;
if (error instanceof UnsafeRootPinError && options.unsafePins !== 'leave') {
reasons.push(
`Root pins left unchanged, Uptide cannot update them safely; check each by hand: ${error.pins.map(describeUnsafePin).join('; ')}`,
);
options = { ...options, unsafePins: 'leave' };
} else if (error instanceof RootPinConsentError && !error.refused && !options.updatePins) {
reasons.push(
`Root pins updated without --update-pins consent: ${error.pins.map(describeRootPin).join(' ')}`,
);
options = { ...options, updatePins: true };
} else throw error;
}
}
}
4 changes: 3 additions & 1 deletion packages/core/src/fix/generic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,9 @@ describe('fix with companions', () => {
'brush 1.0.0 has no release that agrees with paint 2.0.0',
]);
const result = await fix({ cwd: root, only: 'paint', fixer: fixer(0.01) }, services);
expect(installs).toHaveLength(1);
// The upgrade, then the draft's clean install (no upgrade argument).
expect(installs).toHaveLength(2);
expect(installs[1]).toBeUndefined();
expect(installs[0]?.also).toBeUndefined();
expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).dependencies.brush).toBe(
'1.0.0',
Expand Down
63 changes: 62 additions & 1 deletion packages/core/src/fix/isolate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,67 @@ describe('preflight refusals become agent-led drafts with an agent (UPT-78)', ()
expect(body).toMatch(/Feedback rounds: \d\/3\. Spend: \$/);
});

// enki's shape (UPT-102): a nested override Uptide cannot rewrite without guessing.
const unsafePin = () => {
const { root, services } = zodFixture(scratch);
const file = join(root, 'package.json');
const manifest = JSON.parse(readFileSync(file, 'utf8'));
const overrides = { 'zod-form': { peerDependencies: { zod: '3.25.76' } } };
writeFileSync(file, JSON.stringify({ ...manifest, overrides }));
git(root, 'add', '.');
git(root, 'commit', '-m', 'nested pin');
return { root, services, overrides };
};
const exactPin =
'package.json overrides > zod-form > peerDependencies > zod = "3.25.76" (pins zod, which moves to 4.6.5)';

it('pin that cannot be updated safely: exit-2 refusal without an agent or with --no-llm', async () => {
const { root, services } = unsafePin();
for (const options of [
{ cwd: root, only: 'zod', fixer: null, tool: TOOL },
{ cwd: root, ...agent, fixer: null },
])
await expect(isolatedFix(options, services)).rejects.toThrow(
'package.json overrides > zod-form > peerDependencies > zod may pin zod, but its selector or value cannot be updated safely.',
);
});

it('pin that cannot be updated safely: a draft with an agent, the pin left as written and named first', async () => {
const { root, services, overrides } = unsafePin();
const agentRunner = runner();
const result = await isolatedFix({ cwd: root, ...agent }, { ...services, agentRunner });
expect(agentRunner).toHaveBeenCalled();
expect(result.agentFallback?.[0]).toBe(
`Root pins left unchanged, Uptide cannot update them safely; check each by hand: ${exactPin}`,
);
expect(JSON.parse(git(root, 'show', `${result.branch}:package.json`)).overrides).toEqual(
overrides,
);
expect(result.cleanInstall).toMatchObject({ passed: true, command: expect.any(String) });
const body = prBody(result);
expect(body.split('\n').slice(0, 4).join('\n')).toContain(`**Agent-led draft.**`);
expect(body.split('\n').slice(0, 4).join('\n')).toContain(exactPin);
expect(body).not.toContain('does not install');
});

it('a draft whose clean install fails says so at the top and does not pass', async () => {
const { root, services } = unsafePin();
const install: typeof services.install = async (cwd, upgrade) => {
if (upgrade) return services.install(cwd, upgrade);
throw new UptideError('INSTALL_FAILED', 'install failed: npm error code ERESOLVE');
};
const result = await isolatedFix(
{ cwd: root, ...agent },
{ ...services, install, agentRunner: runner() },
);
expect(result.cleanInstall).toMatchObject({ passed: false });
expect(result.cleanInstall?.output).toContain('ERESOLVE');
expect(result.verification.passed).toBe(false);
const top = prBody(result).split('\n').slice(0, 6).join('\n');
expect(top).toContain('**This branch does not install.**');
expect(top).toContain('npm error code ERESOLVE');
});

it('root pin refused in uptide.yml stays refused with an agent', async () => {
const { root, services } = pinned('version: 1\nupdatePins: false\n');
const agentRunner = runner();
Expand Down Expand Up @@ -1064,7 +1125,7 @@ describe('preflight refusals become agent-led drafts with an agent (UPT-78)', ()
expect(planner.mock.calls.some(([options]) => options.also?.includes('zod-form'))).toBe(true);
expect(agentRunner).toHaveBeenCalled();
expect(result.agentFallback?.join(';')).toContain(
'Peer blockers need a major upgrade (zod-form); continued as `uptide fix zod zod-form',
'Peer blockers: zod-form 1.0.0 → 2.0.0, the first release that accepts zod 4.6.5; continued as `uptide fix zod zod-form',
);
// The plan stands: the blocker's major moves in the same install.
expect(result.companions).toMatchObject([{ name: 'zod-form', to: '2.0.0' }]);
Expand Down
27 changes: 13 additions & 14 deletions packages/core/src/fix/isolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { errorCode, SafetyRefusal, UptideError } from '../errors.js';
import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js';
import { activePack, assertCandidateLocal } from '../packs/index.js';
import { uptideVersionInfo } from '../version.js';
import { assistedPeerPreflight, pinConsentFallback } from './feedback.js';
import { assistedPeerPreflight, assistedRootPins } from './feedback.js';
import { assertNpmLockSync } from './managers/npm-sync.js';
import { PeerPreflightError } from './peer-preflight.js';
import { git, projectRoot } from './process.js';
Expand Down Expand Up @@ -596,22 +596,21 @@ export async function isolatedFix(
leadOnly(error);
}
if (options.target) noPackPreflight(options, options.target, assist, committed);
if (!options.pinCurrentApi && options.target)
try {
planRootPins(
options.cwd,
[{ name: options.only, to: options.target }, ...(options.peerPlan?.companions ?? [])],
options,
(file) => {
if (!options.pinCurrentApi && options.target) {
const lead = { name: options.only, to: options.target };
const pins = assistedRootPins(
options,
(o) =>
planRootPins(options.cwd, [lead, ...(o.peerPlan?.companions ?? [])], o, (file) => {
const path = join(project, file);
if (!git(source, 'ls-tree', '--name-only', preflight.head, '--', path)) return undefined;
return git(source, 'show', `${preflight.head}:${path}`);
},
);
} catch (error) {
agentLed.push(pinConsentFallback(error, assist));
options = { ...options, updatePins: true };
}
}),
assist,
);
options = pins.options;
agentLed.push(...pins.reasons);
}
if (agentLed.length)
options = { ...options, agentLed: [...(options.agentLed ?? []), ...agentLed] };
const before = snapshot(source);
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/fix/peer-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { compatiblePeerVersion } from '../check/companions.js';
import type { PackageFetcher } from '../domain/io.js';
import { definePack } from '../packs/contract.js';
import { zodPack } from '../packs/zod/index.js';
import { assistedPeerPreflight } from './feedback.js';
import { isolatedFix } from './isolate.js';
import type { ManagerKind } from './managers/manager.js';
import {
Expand Down Expand Up @@ -806,3 +807,39 @@ it('--no-llm still stops before the clone, with the major as the next command',
expect((error as PeerPreflightError).next).toMatch(/fix zod plugin --target 4\.6\.5 --no-llm$/);
expect(install).not.toHaveBeenCalled();
});

it('an agent-led run names the first release that accepts the target, even when later ones do not (UPT-102)', async () => {
// 3.1.0 asks for a newer zod than the target, as @entur/react-component-toggle 1.1.x does for react 19.2.0.
const { root, services } = majorBlocker('plugin', '1.0.0', {
'1.0.0': { peerDependencies: { zod: '^3' } },
'3.0.0': { peerDependencies: { zod: '^4.1.0' } },
'3.1.0': { peerDependencies: { zod: '^4.7.0' } },
});
const planned = await assistedPeerPreflight(
{ cwd: root, only: 'zod', target: '4.6.5' },
services,
undefined,
true,
);
expect(planned.reason).toMatch(
/^Peer blockers: plugin 1\.0\.0 → 3\.0\.0, the first release that accepts zod 4\.6\.5; continued as `.*fix zod plugin --target 4\.6\.5`$/,
);
expect(planned.plan?.companions).toMatchObject([{ name: 'plugin', to: '3.0.0' }]);
});

it('an agent-led run says when no release accepts the target (UPT-102)', async () => {
const { root, services } = majorBlocker('plugin', '1.0.0', {
'1.0.0': { peerDependencies: { zod: '^3' } },
'2.0.0': { peerDependencies: { zod: '^3' } },
});
const planned = await assistedPeerPreflight(
{ cwd: root, only: 'zod', target: '4.6.5' },
services,
undefined,
true,
);
expect(planned.reason).toContain(
'Peer blockers: no plugin release accepts zod 4.6.5; 1.0.0 kept with a peer override',
);
expect(planned.plan?.conflicts).toMatchObject([{ name: 'plugin', allowed: true }]);
});
5 changes: 5 additions & 0 deletions packages/core/src/fix/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,11 @@ export function renderMigration(
`> ⚠️ **Agent-led draft.** The mechanical path stopped: ${report.agentFallback.join('; ')}`,
'',
);
if (report.cleanInstall?.passed === false)
lines.push(
`> ❌ **This branch does not install.** \`${report.cleanInstall.command}\` failed in the fix clone: ${report.cleanInstall.output?.split('\n').find((l) => l.trim()) ?? 'no output'}`,
'',
);
if (report.lockfile?.outOfScope?.length)
lines.push(
must(
Expand Down
26 changes: 25 additions & 1 deletion packages/core/src/fix/root-pins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterAll, expect, it } from 'vitest';
import { planRootPins, writeRootPins } from './root-pins.js';
import { describeUnsafePin, planRootPins, UnsafeRootPinError, writeRootPins } from './root-pins.js';
import { bumpCatalog, bumpVersions } from './versions.js';

const scratch = mkdtempSync(join(tmpdir(), 'uptide-root-pins-'));
Expand Down Expand Up @@ -139,3 +139,27 @@ it.each([
else expect(plan).toThrow('--update-pins');
},
);

it('names every unsafe pin at once, and an agent-led run leaves them while updating the safe ones', () => {
const root = fixture();
const manifest = {
overrides: {
'@types/react': '18.0.26',
parent: { peerDependencies: { '@types/react': '18.0.26', '@types/react-dom': '18.0.26' } },
},
};
const read = (file: string) => (file === 'package.json' ? JSON.stringify(manifest) : undefined);
let error: unknown;
try {
planRootPins(root, moves, { updatePins: true }, read);
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(UnsafeRootPinError);
expect((error as UnsafeRootPinError).pins.map(describeUnsafePin)).toEqual([
'package.json overrides > parent > peerDependencies > @types/react = "18.0.26" (pins @types/react, which moves to 19.3.0)',
'package.json overrides > parent > peerDependencies > @types/react-dom = "18.0.26" (pins @types/react-dom, which moves to 19.3.0)',
]);
const pins = planRootPins(root, moves, { updatePins: true, unsafePins: 'leave' }, read);
expect(pins).toMatchObject([{ path: ['overrides', '@types/react'], after: '19.3.0' }]);
});
Loading
Loading