From c1b6131faa438bb7daa04952b4e0c6b7461be9a1 Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Sat, 17 Oct 2026 16:54:49 -0700 Subject: [PATCH] fix: unsafe root pins become agent-led drafts, with a clean-install gate (UPT-102) With an assisting agent, a root pin Uptide cannot update safely (enki's `overrides > @reach/dialog > peerDependencies > react`) no longer exits 2. It continues on UPT-78's agentFallback path: the pin is left as written and named, with exact path and value, at the top of the PR body. Every unsafe pin is collected and named at once (UnsafeRootPinError). Exit 2 stays without an agent, with --no-llm, and on SafetyRefusal. The agent-led peer reason names each blocker's first release that accepts the target, or says none does and an override keeps it. An agent-led draft runs the repository's frozen install (npm ci) in the fix clone before delivery. A failure heads the PR body and fails verification. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .changeset/agent-led-unsafe-pins.md | 5 ++ docs/commands/fix.md | 15 +++-- packages/cli/src/format-fix.ts | 5 ++ packages/core/src/fix/feedback.ts | 62 ++++++++++++++++--- packages/core/src/fix/generic.test.ts | 4 +- packages/core/src/fix/isolate.test.ts | 63 +++++++++++++++++++- packages/core/src/fix/isolate.ts | 27 ++++----- packages/core/src/fix/peer-preflight.test.ts | 37 ++++++++++++ packages/core/src/fix/report.ts | 5 ++ packages/core/src/fix/root-pins.test.ts | 26 +++++++- packages/core/src/fix/root-pins.ts | 46 ++++++++++++-- packages/core/src/fix/run.ts | 41 ++++++++++--- packages/core/src/fix/types.ts | 2 + 13 files changed, 296 insertions(+), 42 deletions(-) create mode 100644 .changeset/agent-led-unsafe-pins.md diff --git a/.changeset/agent-led-unsafe-pins.md b/.changeset/agent-led-unsafe-pins.md new file mode 100644 index 0000000..e26be94 --- /dev/null +++ b/.changeset/agent-led-unsafe-pins.md @@ -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. diff --git a/docs/commands/fix.md b/docs/commands/fix.md index d6de787..31074be 100644 --- a/docs/commands/fix.md +++ b/docs/commands/fix.md @@ -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 diff --git a/packages/cli/src/format-fix.ts b/packages/cli/src/format-fix.ts index 12f2cb3..a82eba5 100644 --- a/packages/cli/src/format-fix.ts +++ b/packages/cli/src/format-fix.ts @@ -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)}`, diff --git a/packages/core/src/fix/feedback.ts b/packages/core/src/fix/feedback.ts index 9ba9758..2946529 100644 --- a/packages/core/src/fix/feedback.ts +++ b/packages/core/src/fix/feedback.ts @@ -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'; @@ -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. @@ -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( + 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; + } + } } diff --git a/packages/core/src/fix/generic.test.ts b/packages/core/src/fix/generic.test.ts index d14e4ed..9508522 100644 --- a/packages/core/src/fix/generic.test.ts +++ b/packages/core/src/fix/generic.test.ts @@ -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', diff --git a/packages/core/src/fix/isolate.test.ts b/packages/core/src/fix/isolate.test.ts index 8b679fb..2ae1e64 100644 --- a/packages/core/src/fix/isolate.test.ts +++ b/packages/core/src/fix/isolate.test.ts @@ -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(); @@ -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' }]); diff --git a/packages/core/src/fix/isolate.ts b/packages/core/src/fix/isolate.ts index 0cdd57e..c1d8a92 100644 --- a/packages/core/src/fix/isolate.ts +++ b/packages/core/src/fix/isolate.ts @@ -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'; @@ -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); diff --git a/packages/core/src/fix/peer-preflight.test.ts b/packages/core/src/fix/peer-preflight.test.ts index 60fc401..724a457 100644 --- a/packages/core/src/fix/peer-preflight.test.ts +++ b/packages/core/src/fix/peer-preflight.test.ts @@ -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 { @@ -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 }]); +}); diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index 093bd7c..91edefe 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -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( diff --git a/packages/core/src/fix/root-pins.test.ts b/packages/core/src/fix/root-pins.test.ts index 6da55aa..02acec2 100644 --- a/packages/core/src/fix/root-pins.test.ts +++ b/packages/core/src/fix/root-pins.test.ts @@ -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-')); @@ -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' }]); +}); diff --git a/packages/core/src/fix/root-pins.ts b/packages/core/src/fix/root-pins.ts index f1dad9d..c1bc288 100644 --- a/packages/core/src/fix/root-pins.ts +++ b/packages/core/src/fix/root-pins.ts @@ -23,12 +23,18 @@ export const describeRootPin = (pin: RootPin): string => export function planRootPins( root: string, moves: readonly { name: string; to: string }[], - options: { updatePins?: boolean; pr?: boolean; forPublication?: boolean } = {}, + options: { + updatePins?: boolean; + pr?: boolean; + forPublication?: boolean; + unsafePins?: 'leave'; + } = {}, read = (file: string): string | undefined => existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : undefined, ): RootPin[] { const manifest = JSON.parse(read('package.json') ?? '{}'); const pins: RootPin[] = []; + const unsafe: UnsafeRootPin[] = []; function visit(file: string, path: string[], table: unknown, nested = false): void { if (!table || typeof table !== 'object') return; for (const [key, value] of Object.entries(table)) { @@ -58,11 +64,16 @@ export function planRootPins( (section) => typeof manifest[section]?.[move.name] === 'string', ); if (safe && before === reference && direct && path[0] !== 'resolutions') continue; - if (!safe || typeof before !== 'string' || !validRange(before)) - throw new UptideError( - 'INCONSISTENT_UPGRADE', - `${file} ${field.join(' > ')} may pin ${move.name}, but its selector or value cannot be updated safely. Update or remove that entry manually and rerun. Nothing was changed.`, - ); + if (!safe || typeof before !== 'string' || !validRange(before)) { + unsafe.push({ + file, + path: field, + name: move.name, + value: JSON.stringify(before), + to: move.to, + }); + continue; + } if (satisfies(move.to, before)) continue; pins.push({ file, @@ -93,6 +104,7 @@ export function planRootPins( for (const pin of pins.filter((pin) => pin.file === 'pnpm-workspace.yaml')) assertIndependentYamlPin(doc, pin.path); } + if (unsafe.length && options.unsafePins !== 'leave') throw new UnsafeRootPinError(unsafe); let updatePins = options.updatePins; if (pins.length && updatePins === undefined && (options.pr || options.forPublication)) { const config = read(UPTIDE_CONFIG_FILE); @@ -108,6 +120,28 @@ export function planRootPins( return pins; } +/** A pin Uptide cannot rewrite without guessing: a nested or selector key, or a non-range value. */ +export interface UnsafeRootPin { + file: string; + path: string[]; + name: string; + /** The value as written, JSON-encoded. */ + value: string; + to: string; +} +export const describeUnsafePin = (pin: UnsafeRootPin): string => + `${pin.file} ${pin.path.join(' > ')} = ${pin.value} (pins ${pin.name}, which moves to ${pin.to})`; + +/** Every pin Uptide cannot update safely, named at once. An agent-led run leaves them as they are (UPT-102). */ +export class UnsafeRootPinError extends UptideError { + constructor(readonly pins: UnsafeRootPin[]) { + super( + 'INCONSISTENT_UPGRADE', + `${pins.map((pin) => `${pin.file} ${pin.path.join(' > ')} may pin ${pin.name}, but its selector or value cannot be updated safely.`).join('\n')}\nUpdate or remove ${pins.length === 1 ? 'that entry' : 'those entries'} manually and rerun. Nothing was changed.`, + ); + } +} + /** Pins this upgrade must change, without consent. `refused`: uptide.yml says `updatePins: false`. */ export class RootPinConsentError extends UptideError { constructor( diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index f7cf038..d07c773 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -41,10 +41,10 @@ import { behaviorCheck } from './behavior.js'; import { buildWorkspaces, configuredBuildScript } from './build.js'; import { assistedPeerPreflight, + assistedRootPins, fallbackReport, feedbackFiles, feedbackPatchRunner, - pinConsentFallback, trimOutput, verificationFeedback, } from './feedback.js'; @@ -110,6 +110,8 @@ export interface FixOptions { allowPeer?: string[]; /** Explicit pin consent; otherwise PR runs may update pins unless uptide.yml opts out. */ updatePins?: boolean; + /** Set for an agent-led run: pins Uptide cannot update safely stay as written (UPT-102). */ + unsafePins?: 'leave'; /** Frozen read-only plan made before the CLI clone. */ peerPlan?: PeerPlan; /** Read-only planning failed before isolation; continue with the lead and record why. */ @@ -349,12 +351,14 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise + planRootPins(root, [{ name: pack.name, to: target }, ...(peerPlan?.companions ?? [])], o), + canAssist, + ); + options = pinPlan.options; + agentLed.push(...pinPlan.reasons); // A peer blocker moved across a major its pack covers: that pack's rules run here too. const companionPacks = (peerPlan?.companions ?? []).flatMap((c) => { const companion = @@ -1098,6 +1102,8 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise t.status === 'passed' || t.status === 'missing' || t.preexisting) && (!pack.requiresBuild || (build.length > 0 && build.every((b) => b.status === 'passed'))) && @@ -1313,3 +1321,22 @@ export function refreshReview( export { selectedFindings } from './select.js'; export { safeFile } from './settle.js'; + +/** The frozen install (`npm ci`, `pnpm install --frozen-lockfile`, …) on the branch as committed. */ +async function cleanInstallCheck( + root: string, + services: FixServices, +): Promise> { + const pm = packageManager(root); + const command = [pm.bin, ...pm.args].join(' '); + try { + await services.install(root); + return { command, passed: true }; + } catch (error) { + return { + command, + passed: false, + output: trimOutput(error instanceof Error ? error.message : String(error)), + }; + } +} diff --git a/packages/core/src/fix/types.ts b/packages/core/src/fix/types.ts index a9df1ec..c368b2d 100644 --- a/packages/core/src/fix/types.ts +++ b/packages/core/src/fix/types.ts @@ -212,6 +212,8 @@ export interface FixReport { rootPins?: RootPin[]; /** Pack pipeline limitations: agent-led results can be published only as drafts. */ agentFallback?: string[]; + /** Agent-led runs: the repository's frozen install on the final branch, run in the fix clone. */ + cleanInstall?: { command: string; passed: boolean; output?: string }; /** A local candidate smoke run; never eligible for publication. */ packWarning?: 'candidate pack, not verified'; peerConflicts?: PeerBlockerGroup[];