From 30b580b890202ee09061f2174b5ef5c58da6922e Mon Sep 17 00:00:00 2001 From: Lucas Braum Date: Tue, 20 Oct 2026 00:38:36 -0700 Subject: [PATCH] fix(peers): keep a blocker that only an unpacked major accepts, with a scoped peer override (UPT-125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peerPreflight suggested every blocker's first accepting release as an extra upgrade, and the agent-led continuation took that plan: redis 5 → 6 moved typeorm 0.3.31 → 1.1.0, a major no pack migrates. A blocker that only such a major accepts now keeps its version with the peer override `--allow-peer` writes (npm overrides, pnpm peerDependencyRules, Yarn resolutions), inside the lockfile scope check. The PR body names the override and the major not taken; report.json records `declined`, `override` and `strategy` (override, blocked when verification fails with it, none). A named or packed blocker is upgraded as before. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Lucas Braum --- .../peer-override-not-unpacked-major.md | 5 + packages/cli/src/format-fix.ts | 2 +- packages/core/src/fix/feedback.ts | 2 + .../fix/managers/compatible-ranges.test.ts | 85 ++++++++++++ packages/core/src/fix/peer-override.test.ts | 131 ++++++++++++++++++ packages/core/src/fix/peer-preflight.test.ts | 37 +++-- packages/core/src/fix/peer-preflight.ts | 90 ++++++++++-- packages/core/src/fix/report.ts | 13 +- packages/core/src/fix/run.ts | 21 ++- 9 files changed, 346 insertions(+), 40 deletions(-) create mode 100644 .changeset/peer-override-not-unpacked-major.md create mode 100644 packages/core/src/fix/peer-override.test.ts diff --git a/.changeset/peer-override-not-unpacked-major.md b/.changeset/peer-override-not-unpacked-major.md new file mode 100644 index 00000000..61ac5db1 --- /dev/null +++ b/.changeset/peer-override-not-unpacked-major.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +`uptide fix` no longer upgrades a peer blocker across a major that no migration pack covers. Before, `redis` 5 → 6 next to `typeorm` 0.3 (which peers on redis ^5) moved typeorm to 1.1.0 and ended in an agent-led draft. Now the blocker keeps its version, with a peer override scoped to it in package.json: npm `overrides..`, pnpm `pnpm.peerDependencyRules.allowedVersions`, Yarn `resolutions["/"]`. The lockfile scope check still covers the install. The PR body names the override, which release was not taken and why. In `report.json`, each `peerConflicts` entry now has `strategy` (`override`, `blocked` or `none`), `declined` and `override`. If verification fails with the override, the blocker is reported as a blocker and is still not upgraded. A blocker you name on the command line, or one whose major a pack covers, is upgraded as before. diff --git a/packages/cli/src/format-fix.ts b/packages/cli/src/format-fix.ts index db5e5b17..1f194142 100644 --- a/packages/cli/src/format-fix.ts +++ b/packages/cli/src/format-fix.ts @@ -110,7 +110,7 @@ export function formatFixSummary(report: FixReport, opts: FormatFixOptions = {}) lines.push(colors.bold('Peer risks')); for (const peer of groupPeerBlockers(report.peerConflicts)) lines.push( - ` ${peer.name} ${peer.version}: ${peer.peers.map((p) => `${p.peer} ${p.range} rejects ${p.target}`).join('; ')}${peer.allowed ? ' · explicitly allowed in package.json' : ''}`, + ` ${peer.name} ${peer.version}: ${peer.peers.map((p) => `${p.peer} ${p.range} rejects ${p.target}`).join('; ')}${peer.declined ? ` · kept with a peer override (${peer.declined} is a major with no migration pack)${peer.strategy === 'blocked' ? '; verification failed: blocker' : ''}` : peer.allowed ? ' · explicitly allowed in package.json' : ''}`, ); lines.push(''); } diff --git a/packages/core/src/fix/feedback.ts b/packages/core/src/fix/feedback.ts index 29465296..5ab193f2 100644 --- a/packages/core/src/fix/feedback.ts +++ b/packages/core/src/fix/feedback.ts @@ -208,6 +208,8 @@ function blockerOutcome(p: PeerBlockerGroup, allowPeer: readonly string[]): stri const peers = [ ...new Set(p.peers.filter((r) => r.version === p.version).map((r) => `${r.peer} ${r.target}`)), ].join(', '); + if (p.declined) + return `${p.name} ${p.version} kept with a peer override: ${p.declined} accepts ${peers} but is a major with no migration pack`; if (p.newer && !allowPeer.includes(p.name)) return `${p.name} ${p.version} → ${p.newer}, the first release that accepts ${peers}`; return p.via diff --git a/packages/core/src/fix/managers/compatible-ranges.test.ts b/packages/core/src/fix/managers/compatible-ranges.test.ts index d81f7a26..d245cc9d 100644 --- a/packages/core/src/fix/managers/compatible-ranges.test.ts +++ b/packages/core/src/fix/managers/compatible-ranges.test.ts @@ -5,6 +5,7 @@ import { expect, it } from 'vitest'; import { parse, stringify } from 'yaml'; import { parseNpm, parsePnpm, parseYarn } from '../../adapters/typescript/lockfile.js'; import { installedManifest } from '../../list/evidence.js'; +import { allowPeerOverrides } from '../peer-preflight.js'; import { command, git } from '../process.js'; import { bumpVersions } from '../versions.js'; import { assertManagerAvailable } from './availability.js'; @@ -271,3 +272,87 @@ it.runIf(process.env.UPTIDE_NETWORK === '1')( }, 180_000, ); + +// UPT-125: typeorm 0.3.31 peers on redis ^5; only typeorm 1, a major no pack migrates, +// accepts redis 6. The scoped override Uptide writes lets each manager install redis 6 with +// typeorm kept, and the lockfile scope check still holds. +it.runIf(process.env.UPTIDE_NETWORK === '1').each([ + { manager: 'npm', pin: undefined }, + { manager: 'pnpm', pin: 'pnpm@10.17.1' }, + { manager: 'yarn', pin: 'yarn@1.22.22' }, + { manager: 'yarn', pin: 'yarn@4.9.2' }, +])( + '$manager ($pin) installs redis 6 next to typeorm 0.3 with the peer override', + async ({ manager, pin }) => { + const root = mkdtempSync(join(tmpdir(), 'uptide-peer-override-')); + const read = (file: string) => readFileSync(join(root, file), 'utf8'); + const put = (file: string, text: string) => writeFileSync(join(root, file), text); + const run = async (bin: string, args: string[]) => { + const result = await command(root, bin, args, 180_000, { + YARN_ENABLE_IMMUTABLE_INSTALLS: 'false', + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', + COREPACK_ENABLE_AUTO_PIN: '0', + }); + expect(result.code, result.output).toBe(0); + }; + try { + put( + 'package.json', + JSON.stringify({ + name: 'peer-override', + private: true, + ...(pin ? { packageManager: pin } : {}), + dependencies: { redis: '^5.8.0', typeorm: '0.3.31' }, + }), + ); + put('.gitignore', 'node_modules\n.yarn\n'); + if (pin === 'yarn@4.9.2') + put('.yarnrc.yml', 'nodeLinker: node-modules\nenableGlobalCache: true\n'); + await run( + pin?.startsWith('yarn') ? 'corepack' : manager, + pin?.startsWith('yarn') ? ['yarn', 'install'] : ['install', '--ignore-scripts'], + ); + const pm = await assertManagerAvailable(root); + git(root, 'init'); + git(root, 'add', '.'); + git( + root, + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.test', + 'commit', + '-m', + 'baseline', + ); + const files = bumpVersions(root, 'redis', '6.3.0').files.map((f) => relative(root, f)); + const upgrade = { name: 'redis', version: '6.3.0', files, workspaces: ['.'] }; + if (pm.kind === 'npm') + // Without the override npm refuses: the override is what makes the upgrade install. + await expect(isolatedUpgrade(root, upgrade)).rejects.toThrow(/ERESOLVE/); + const manifest = JSON.parse(read('package.json')); + allowPeerOverrides(manifest, pm.kind, [ + { + name: 'typeorm', + version: '0.3.31', + peer: 'redis', + range: '^3.1.1 || ^4.0.0 || ^5.0.14', + target: '6.3.0', + allowed: true, + declined: '1.1.0', + }, + ]); + put('package.json', `${JSON.stringify(manifest, null, 2)}\n`); + const report = await isolatedUpgrade(root, { ...upgrade, allowedPeers: ['typeorm'] }); + expect(report.outOfScope).toBeUndefined(); + expect(installedManifest(root, '.', 'redis')?.version).toBe('6.3.0'); + expect(installedManifest(root, '.', 'typeorm')?.version).toBe('0.3.31'); + const upgradedLock = read(pm.lockfile); + await run(pm.bin, pm.args); + expect(read(pm.lockfile)).toBe(upgradedLock); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 300_000, +); diff --git a/packages/core/src/fix/peer-override.test.ts b/packages/core/src/fix/peer-override.test.ts new file mode 100644 index 00000000..8f21e3f8 --- /dev/null +++ b/packages/core/src/fix/peer-override.test.ts @@ -0,0 +1,131 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, expect, it, vi } from 'vitest'; +import type { PackageFetcher } from '../domain/io.js'; +import type { ManagerKind } from './managers/manager.js'; +import { git } from './process.js'; +import { prBody } from './report.js'; +import { type FixServices, fix } from './run.js'; +import { zodFixture } from './test-fixture.js'; + +// UPT-125: `plugin` 1.0.0 peers on zod ^3 and only plugin 2.0.0, a major no pack migrates, +// accepts zod 4. Uptide keeps plugin 1.0.0 with a peer override scoped to it, never the major. +const scratch = mkdtempSync(join(tmpdir(), 'uptide-peer-override-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +const fields: Record = { + npm: [['overrides'], { plugin: { zod: '$zod' } }], + pnpm: [['pnpm', 'peerDependencyRules', 'allowedVersions'], { 'plugin>zod': '4.6.5' }], + 'yarn-classic': [['resolutions'], { 'plugin/zod': '4.6.5' }], + 'yarn-berry': [['resolutions'], { 'plugin/zod': '4.6.5' }], +}; +const shown: Record = { + npm: '`overrides.plugin.zod`', + pnpm: '`pnpm.peerDependencyRules.allowedVersions["plugin>zod"]`', + 'yarn-classic': '`resolutions["plugin/zod"]`', + 'yarn-berry': '`resolutions["plugin/zod"]`', +}; + +function blocked(manager: ManagerKind) { + const f = zodFixture(scratch); + const json = JSON.parse(readFileSync(join(f.root, 'package.json'), 'utf8')); + json.dependencies.plugin = '^1.0.0'; + if (manager === 'yarn-berry') json.packageManager = 'yarn@4.9.2'; + writeFileSync(join(f.root, 'package.json'), JSON.stringify(json)); + mkdirSync(join(f.root, 'node_modules/plugin')); + writeFileSync( + join(f.root, 'node_modules/plugin/package.json'), + JSON.stringify({ name: 'plugin', version: '1.0.0', peerDependencies: { zod: '^3' } }), + ); + if (manager !== 'pnpm') rmSync(join(f.root, 'pnpm-lock.yaml')); + if (manager === 'npm') + writeFileSync( + join(f.root, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': json, + 'node_modules/zod': { version: '3.25.76' }, + 'node_modules/plugin': { version: '1.0.0', peerDependencies: { zod: '^3' } }, + }, + }), + ); + if (manager === 'yarn-classic') writeFileSync(join(f.root, 'yarn.lock'), '# yarn lockfile v1\n'); + if (manager === 'yarn-berry') + writeFileSync(join(f.root, 'yarn.lock'), '__metadata:\n version: 8\n'); + git(f.root, 'add', '.'); + git(f.root, 'commit', '-m', 'peer blocker'); + type Manifests = NonNullable; + const manifests = vi.fn( + async (name): ReturnType => + name === 'zod' + ? { '4.6.5': {} } + : { + '1.0.0': { peerDependencies: { zod: '^3' } }, + '2.0.0': { peerDependencies: { zod: '^3 || ^4' } }, + }, + ); + // Only what the manager would write: the target installed, for the version checks. + const install = vi.fn(async (dir: string, _upgrade?: Parameters[1]) => { + const file = join(dir, 'node_modules/zod/package.json'); + writeFileSync( + file, + JSON.stringify({ ...JSON.parse(readFileSync(file, 'utf8')), version: '4.6.5' }), + ); + writeFileSync( + join(dir, 'node_modules/zod/index.d.ts'), + 'export declare const z: { string(options: {error:(iss:{input:unknown})=>string|undefined}): string };', + ); + }); + return { root: f.root, services: { ...f.services, manifests, install } }; +} + +it.each(Object.keys(fields) as ManagerKind[])( + '%s keeps the blocker on its major with a scoped peer override, listed in the report and PR body', + async (manager) => { + const { root, services } = blocked(manager); + const result = await fix({ cwd: root, only: 'zod', target: '4.6.5', fixer: null }, services); + const [path, expected] = fields[manager]; + const committed = JSON.parse(git(root, 'show', `${result.branch}:package.json`)); + expect(path.reduce((o, k) => o?.[k], committed)).toEqual(expected); + expect(committed.dependencies.plugin).toBe('^1.0.0'); + // The override's package is inside the lockfile scope check, and nothing else moves with it. + expect(services.install.mock.calls[0]?.[1]).toMatchObject({ allowedPeers: ['plugin'] }); + expect(services.install.mock.calls[0]?.[1]).not.toHaveProperty('also'); + expect(result.companions).toBeUndefined(); + expect(result.verification.passed).toBe(true); + expect(result.peerConflicts).toMatchObject([ + { name: 'plugin', version: '1.0.0', allowed: true, declined: '2.0.0', strategy: 'override' }, + ]); + expect(prBody(result)).toContain( + `Uptide added a peer override to package.json: ${shown[manager]}. 2.0.0 accepts zod 4.6.5 but is a major with no migration pack, so Uptide did not upgrade plugin; it stays at 1.0.0.`, + ); + }, +); + +it('reports the blocker, without the major, when verification fails with the override', async () => { + const { root, services } = blocked('npm'); + let runs = 0; + const result = await fix( + { cwd: root, only: 'zod', target: '4.6.5', fixer: null }, + { + ...services, + // The baseline passes; every run after the upgrade fails. + tests: async () => [ + { workspace: '.', status: ++runs > 1 ? 'failed' : 'passed', output: 'plugin broke' }, + ], + }, + ); + expect(result.verification.passed).toBe(false); + expect(result.companions).toBeUndefined(); + expect(JSON.parse(git(root, 'show', `${result.branch}:package.json`)).dependencies.plugin).toBe( + '^1.0.0', + ); + expect(result.peerConflicts).toMatchObject([ + { name: 'plugin', declined: '2.0.0', strategy: 'blocked' }, + ]); + expect(prBody(result)).toContain( + '- **Blocker:** plugin 1.0.0 declares zod ^3, which rejects 4.6.5. Tried: a peer override Uptide added to package.json (`overrides.plugin.zod`); verification failed with it. 2.0.0 accepts zod 4.6.5 but is a major with no migration pack, so Uptide did not upgrade plugin: it needs its own migration.', + ); +}); diff --git a/packages/core/src/fix/peer-preflight.test.ts b/packages/core/src/fix/peer-preflight.test.ts index 724a457c..4088ad01 100644 --- a/packages/core/src/fix/peer-preflight.test.ts +++ b/packages/core/src/fix/peer-preflight.test.ts @@ -187,7 +187,13 @@ it('allowed peers are committed with the upgrade and listed under PR risks with { cwd: root, only: 'zod', target: '4.6.5', fixer: null, allowPeer: ['plugin'] }, { ...services, install }, ); - expect(result.peerConflicts).toEqual(groupPeerBlockers([blocker])); + expect(result.peerConflicts).toEqual( + groupPeerBlockers([blocker]).map((g) => ({ + ...g, + override: 'pnpm.peerDependencyRules.allowedVersions["plugin>zod"]', + strategy: 'override', + })), + ); expect(prBody(result)).toContain('### Peer risks'); expect(prBody(result)).toContain('plugin 1.0.0 declares zod ^3'); expect(git(root, 'show', 'HEAD:package.json')).toContain('plugin>zod'); @@ -742,15 +748,6 @@ it.each([ 'no 4.x accepts zod 4; 5.0.0 does (major, the express pack runs in the same fix): add express to the command', migration: 'pack', }, - { - covered: 'no pack covers the major', - name: 'plugin', - from: '1.0.0', - to: '2.0.0', - advice: - 'no 1.x accepts zod 4; 2.0.0 does (major, no migration pack: it needs its own migration): add plugin to the command, or keep 1.x with --allow-peer plugin', - migration: 'own', - }, ])( 'names the first major that accepts the target when $covered', async ({ name, from, to, advice, migration }) => { @@ -793,9 +790,9 @@ it('keeps --allow-peer when no major accepts the target', async () => { }); it('--no-llm still stops before the clone, with the major as the next command', async () => { - const { root, services } = majorBlocker('plugin', '1.0.0', { - '1.0.0': { peerDependencies: { zod: '^3' } }, - '2.0.0': { peerDependencies: { zod: '^3 || ^4' } }, + const { root, services } = majorBlocker('express', '4.0.0', { + '4.0.0': { peerDependencies: { zod: '^3' } }, + '5.0.0': { peerDependencies: { zod: '^3 || ^4' } }, }); const install = vi.fn(services.install); const error = await isolatedFix( @@ -804,16 +801,16 @@ it('--no-llm still stops before the clone, with the major as the next command', ).catch((e: unknown) => e); // The CLI exits 2 on a PeerPreflightError (cli.test.ts). expect(error).toBeInstanceOf(PeerPreflightError); - expect((error as PeerPreflightError).next).toMatch(/fix zod plugin --target 4\.6\.5 --no-llm$/); + expect((error as PeerPreflightError).next).toMatch(/fix zod express --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 { root, services } = majorBlocker('express', '4.0.0', { + '4.0.0': { peerDependencies: { zod: '^3' } }, + '5.0.0': { peerDependencies: { zod: '^4.1.0' } }, + '5.1.0': { peerDependencies: { zod: '^4.7.0' } }, }); const planned = await assistedPeerPreflight( { cwd: root, only: 'zod', target: '4.6.5' }, @@ -822,9 +819,9 @@ it('an agent-led run names the first release that accepts the target, even when 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`$/, + /^Peer blockers: express 4\.0\.0 → 5\.0\.0, the first release that accepts zod 4\.6\.5; continued as `.*fix zod express --target 4\.6\.5`$/, ); - expect(planned.plan?.companions).toMatchObject([{ name: 'plugin', to: '3.0.0' }]); + expect(planned.plan?.companions).toMatchObject([{ name: 'express', to: '5.0.0' }]); }); it('an agent-led run says when no release accepts the target (UPT-102)', async () => { diff --git a/packages/core/src/fix/peer-preflight.ts b/packages/core/src/fix/peer-preflight.ts index fe156d79..2c8ca08d 100644 --- a/packages/core/src/fix/peer-preflight.ts +++ b/packages/core/src/fix/peer-preflight.ts @@ -36,6 +36,11 @@ export interface PeerBlocker { * then the installed version. The path npm takes to it: `['vitest 5.0.5']` for vite 8.3.4. */ via?: string[]; + /** + * The release that accepts the peer but is a major no pack migrates, not taken: the blocker + * keeps its version with a peer override scoped to it (UPT-125). + */ + declined?: string; } export interface PeerBlockerGroup { name: string; @@ -44,6 +49,14 @@ export interface PeerBlockerGroup { newer?: string; allowed: boolean; via?: string[]; + declined?: string; + /** + * What the run did about it: a peer override (`override`), the override and verification + * failed while the only accepting release is an unpacked major (`blocked`), or nothing (`none`). + */ + strategy?: 'override' | 'blocked' | 'none'; + /** The package.json field the override is in (`overrides.typeorm.redis`). */ + override?: string; } /** One row per package, retaining every rejected peer (including look-ahead versions). */ @@ -60,6 +73,9 @@ export function groupPeerBlockers( peers: [], allowed: p.allowed, ...(p.via ? { via: p.via } : {}), + ...(p.declined ? { declined: p.declined } : {}), + ...('strategy' in p && p.strategy ? { strategy: p.strategy } : {}), + ...('override' in p && p.override ? { override: p.override } : {}), }; groups.set(p.name, group); } @@ -135,15 +151,17 @@ function describe(p: PeerBlockerGroup, allowCandidate?: boolean): string { const installed = p.peers.map((r) => `${r.peer} ${r.target}`).join(', '); const major = majorMove(p.name, p.version, p.newer, allowCandidate); // --allow-peer is the fallback, never the first suggestion, when a release accepts the target. - const advice = p.allowed - ? 'Explicitly allowed; a manifest override will be written.' - : p.via - ? `rejects installed ${installed}: upgrade ${p.peers.map((r) => r.peer).join(', ')} into range first, or use --allow-peer ${p.name}` - : major - ? `no ${major.major}.x accepts ${accepts}; ${p.newer} does (major, ${major.pack ? `the ${p.name} pack runs in the same fix` : 'no migration pack: it needs its own migration'}): add ${p.name} to the command${major.pack ? '' : `, or keep ${major.major}.x with --allow-peer ${p.name}`}` - : p.newer - ? `upgrade to ${p.newer} (accepts ${accepts}): add ${p.name} to the command` - : `no release accepts ${accepts}: use --allow-peer ${p.name}`; + const advice = p.declined + ? `kept at ${p.version} with a peer override: ${p.declined} accepts ${accepts} but is a major with no migration pack` + : p.allowed + ? 'Explicitly allowed; a manifest override will be written.' + : p.via + ? `rejects installed ${installed}: upgrade ${p.peers.map((r) => r.peer).join(', ')} into range first, or use --allow-peer ${p.name}` + : major + ? `no ${major.major}.x accepts ${accepts}; ${p.newer} does (major, the ${p.name} pack runs in the same fix): add ${p.name} to the command` + : p.newer + ? `upgrade to ${p.newer} (accepts ${accepts}): add ${p.name} to the command` + : `no release accepts ${accepts}: use --allow-peer ${p.name}`; return `${p.name} ${p.version}${p.via?.length ? ` (peer of ${p.via.join(' → ')})` : ''} ${p.peers.length === 1 ? 'peer' : 'peers'} ${peers} → ${advice}`; } @@ -368,7 +386,33 @@ export async function peerPreflight( await add(moves, next); } }; - const actual = await resolveExtras(requested); + // UPT-125: a blocker that only a major without a pack accepts keeps its version, with a peer + // override scoped to it. That major is a migration of its own that nothing here verifies. + const declined = new Map(); + const unpackedMajor = (p: PeerBlocker): boolean => + !p.via && + !requested.has(p.name) && + p.newer !== undefined && + majorMove( + p.name, + deps.find((d) => d.name === p.name)?.version ?? p.version, + p.newer, + options.allowCandidate, + )?.pack === false; + const decline = (peers: Iterable): boolean => { + let more = false; + for (const p of peers) + if (!declined.has(p.name) && unpackedMajor(p)) { + declined.set(p.name, p.newer as string); + allowed.add(p.name); + more = true; + } + return more; + }; + let actual = await resolveExtras(requested); + if (decline(actual.peers)) actual = await resolveExtras(requested); + const marked = (peers: PeerBlocker[]): PeerBlocker[] => + peers.map((p) => (declined.has(p.name) ? { ...p, declined: declined.get(p.name) } : p)); assertNoDowngrades(options.cwd, [...actual.moves.values()]); const invalid = [...requested].filter( (name) => !actual.moves.has(name) && !actual.peers.some((p) => p.name === name), @@ -392,6 +436,10 @@ export async function peerPreflight( const allowances = new Set(allowed); for (;;) { const preview = await resolveExtras(extras); + if (decline(history.values())) { + for (const name of declined.keys()) allowances.add(name); + continue; + } // A suggested extra can pull another blocker in as a companion. Keep every advertised // upgrade explicit, then replay the whole command before considering it complete. const advertised = [...history.values()].filter( @@ -419,7 +467,7 @@ export async function peerPreflight( ...(options.fixer === null ? ['--no-llm'] : []), ...[...allowances].flatMap((name) => ['--allow-peer', name]), ].join(' '); - const blockers = groupPeerBlockers([...history.values()]); + const blockers = groupPeerBlockers(marked([...history.values()])); throw new PeerPreflightError( blockers, command, @@ -427,7 +475,7 @@ export async function peerPreflight( { also: [...extras], allowPeer: [...allowances] }, ); } - for (const peer of groupPeerBlockers(actual.peers)) + for (const peer of groupPeerBlockers(marked(actual.peers))) options.onProgress?.({ phase: 'resolve', state: 'done', @@ -440,7 +488,7 @@ export async function peerPreflight( targetSource, ...(releaseAge ? { releaseAge } : {}), companions, - conflicts: actual.peers, + conflicts: marked(actual.peers), }; } @@ -483,6 +531,22 @@ export function allowPeerOverrides( } } +/** Where `allowPeerOverrides` writes a blocker's override, as the reviewer finds it in package.json. */ +export function overrideFields( + manager: string, + p: Pick, +): string { + return [...new Set(p.peers.map((r) => r.peer))] + .map((peer) => + manager === 'npm' + ? `overrides.${p.name}.${peer}` + : manager === 'pnpm' + ? `pnpm.peerDependencyRules.allowedVersions["${p.name}>${peer}"]` + : `resolutions["${p.name}/${peer}"]`, + ) + .join(', '); +} + export function writePeerOverrides(root: string, peers: PeerBlocker[]): string[] { if (!peers.some((p) => p.allowed)) return []; const file = join(root, 'package.json'); diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index d628f858..cd6c961d 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -1231,10 +1231,15 @@ export function renderMigration( [ '### Peer risks', '', - ...groupPeerBlockers(report.peerConflicts).map( - (p) => - `- ${peerDescription(p)}. ${p.allowed ? 'Explicitly allowed with `--allow-peer`; the manager override is in package.json.' : 'Not overridden; the package manager may warn.'} Runtime compatibility needs review.`, - ), + ...groupPeerBlockers(report.peerConflicts).map((p) => { + const fields = `\`${p.override}\``; + const declined = `${p.declined} accepts ${[...new Set(p.peers.map((r) => `${r.peer} ${r.target}`))].join(', ')} but is a major with no migration pack, so Uptide did not upgrade ${p.name}`; + return p.declined && p.strategy === 'blocked' + ? `- **Blocker:** ${peerDescription(p)}. Tried: a peer override Uptide added to package.json (${fields}); verification failed with it. ${declined}: it needs its own migration.` + : p.declined + ? `- ${peerDescription(p)}. Uptide added a peer override to package.json: ${fields}. ${declined}; it stays at ${p.version}. Runtime compatibility needs review.` + : `- ${peerDescription(p)}. ${p.allowed ? 'Explicitly allowed with `--allow-peer`; the manager override is in package.json.' : 'Not overridden; the package manager may warn.'} Runtime compatibility needs review.`; + }), ].join('\n'), ), '', diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 38eeb757..128c679c 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -69,7 +69,12 @@ import { assertManagerAvailable } from './managers/availability.js'; import { assertNpmLockSync } from './managers/npm-sync.js'; import type { InstallReport, Upgrade } from './managers/upgrade.js'; import { isMajorUpgrade, NO_PACK_YET } from './no-pack.js'; -import { groupPeerBlockers, type PeerPlan, writePeerOverrides } from './peer-preflight.js'; +import { + groupPeerBlockers, + overrideFields, + type PeerPlan, + writePeerOverrides, +} from './peer-preflight.js'; import { pinCurrentApi } from './pin.js'; import { command, git, projectRoot } from './process.js'; import { publicationBlockers } from './publish.js'; @@ -1417,7 +1422,19 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise ({ + ...g, + ...(g.allowed ? { override: overrideFields(packageManager(root).kind, g) } : {}), + strategy: !g.allowed + ? ('none' as const) + : g.declined && !(checksPassed && !broken) + ? ('blocked' as const) + : ('override' as const), + })), + } + : {}), ...(report.importersNotAnalyzed?.length ? { importersNotAnalyzed: report.importersNotAnalyzed } : {}),