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/peer-override-not-unpacked-major.md
Original file line number Diff line number Diff line change
@@ -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.<blocker>.<peer>`, pnpm `pnpm.peerDependencyRules.allowedVersions`, Yarn `resolutions["<blocker>/<peer>"]`. 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.
2 changes: 1 addition & 1 deletion packages/cli/src/format-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/fix/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/fix/managers/compatible-ranges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
);
131 changes: 131 additions & 0 deletions packages/core/src/fix/peer-override.test.ts
Original file line number Diff line number Diff line change
@@ -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<ManagerKind, [string[], unknown]> = {
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<ManagerKind, string> = {
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<PackageFetcher['manifests']>;
const manifests = vi.fn<Manifests>(
async (name): ReturnType<Manifests> =>
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<FixServices['install']>[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.',
);
});
37 changes: 17 additions & 20 deletions packages/core/src/fix/peer-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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(
Expand All @@ -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' },
Expand All @@ -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 () => {
Expand Down
Loading
Loading