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/required-moves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'uptide': minor
---

`check` and `fix` plan what the target version requires: a package the target, or anything moving with it, declares as a peer or dependency at a range the installed copy rejects moves to the lowest release of its major that satisfies it (openai 7 moves zod 3.24.2 to 3.25.0), shown apart from release-group companions ("moved because openai 7.4.0 requires zod ^3.25 || ^4.0"). A move only a new major makes is never made: it is reported as a blocker with the exact range in `check`, the PR body and `pack test`. The npm install scope guard no longer lets a declared root dependency change because a moved package peers on it.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ npx uptide pr --branch uptide/zod-4.6.5 # prints the plan; add --yes to push
`npx uptide` with no command shows where zod and stripe stand: installed, latest, majors
behind. `fix` preserves workspace and companion ranges that already accept the target,
reports them as **already compatible**, and widens rejecting peer ranges without dropping
existing alternatives. The lockfile and installed target and companions must still match
the exact planned versions; `fix` and `verify` fail if installed versions differ.
existing alternatives. What the target requires moves within its major, else is a blocker.
Lockfile, target and companions must match the planned versions, or `fix` and `verify` fail.
Everything else: [getting started](docs/getting-started.md).

## Why Uptide
Expand Down
16 changes: 16 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ follows (the same one, or an ancestor). `check react` 18 → 19 moves react-dom,
and @types/react-dom together; the plan is carried by every report of the package, alone or
leading its group, and the merged entry keeps every companion.

What the planned versions require is planned after the group (`src/check/required.ts`). A
package the target, or any package the plan moves, declares as a peer or dependency at a range
the installed copy does not satisfy (openai 7 peers on `zod ^3.25 || ^4.0`; zod 3.24.2 is
installed) moves to the lowest release of its line that satisfies it: zod 3.25.0. A range the
installed host already broke (react 19.2.0 under `@ai-sdk/react` 3's `^19.2.1`) is not this
upgrade's and stays. A direct dependency whose own dependency range rejects the lead's target
moves the same way. A line is what a caret
range of the installed version reaches: one major, one minor for 0.x. Each is a companion
marked `required`, shown apart from the group ("moved because openai 7.4.0 requires zod
^3.25 || ^4.0"), and `fix` installs it with the rest. A move only a new major makes is never
made: a peer or a dependent is a `companionBlockers` entry with the exact range (`openai
7.9.0 needs zod ^4.0.0; installed 3.25.76, and only a new major satisfies it (zod 4.0.0)`),
in `check`, the PR body and `pack test`; a plain dependency gets its own nested copy. The
install scope guard allows exactly these moves: an importer's declared dependency changes only
when the plan names it, even where npm resolves it as a moved package's peer.

### One dependency, one decision

A dependency at the same installed version and target in several workspaces is one entry
Expand Down
4 changes: 3 additions & 1 deletion docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@ versions it chose (from its `package.json` at the upgrade commit). `pack test` p
`check` moves with the package there and fails when it would leave one of them behind, or
cannot move one consistently: a pack whose `fix` produces an install the real upgrade never
had is not verified. Versions may differ (check picks the release that agrees with `to`);
the names may not.
the names may not. A package the target requires counts as moved at the lowest release of its
major that satisfies it (batch-ai's zod 3.24.2 → 3.25.0, where its upgrade chose 4.4.3); one
only a new major satisfies is a blocker, printed with its range, and stays left behind.

`fixture` entries (`"fixture": "fixtures/repos/storefront"`) are repositories in this tree.
They are scored like the others and never count toward `verified`.
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/format-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,42 @@ describe('formatCheck, the first screen', () => {
expect(out).toContain('npx uptide@next fix ai');
});

it('names a move the target requires, and a blocker it will not move across a major', () => {
const out = formatCheck(
report([
pkg({
name: 'openai',
installed: '4.83.0',
latest: '7.4.0',
target: '7.4.0',
majorsBehind: 3,
status: 'breaking',
findings: [removed('src/index.ts', 3)],
companions: [
{
name: 'zod',
from: '3.24.2',
to: '3.25.0',
reason: 'openai 7.4.0 requires zod ^3.25 || ^4.0',
required: true,
},
],
companionBlockers: [
'openai 7.4.0 needs ws ^8.18.0; installed 7.5.10, and only a new major satisfies it (ws 8.18.0)',
],
}),
]),
{ ...shown, fixable: ['openai'] },
);
expect(out).toContain(
'↑ moves zod 3.24.2 → 3.25.0 moved because openai 7.4.0 requires zod ^3.25 || ^4.0',
);
expect(out).not.toContain('upgrades with');
expect(out).toContain(
'✗ blocked: openai 7.4.0 needs ws ^8.18.0; installed 7.5.10, and only a new major satisfies it (ws 8.18.0) a new major, never made by Uptide',
);
});

it('lists a peer conflict as possible impact, never as breaking', () => {
const out = formatCheck(
report([
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/format-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,20 @@ function sectionLines(row: Row, colors: Colors): string[] {
);
}
// What moves with it: one install, at versions that agree, the way `fix` upgrades it.
const companions = row.p.companions ?? [];
const companions = (row.p.companions ?? []).filter((c) => !c.required);
if (companions.length > 0)
lines.push(
` ${colors.cyan('↑')} upgrades with ${companions.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')} ${colors.dim(companions.map((c) => c.reason).join('; '))}`,
);
// Not released with it: the planned versions require them, within their majors.
for (const c of (row.p.companions ?? []).filter((c) => c.required))
lines.push(
` ${colors.cyan('↑')} moves ${c.name} ${c.from} → ${c.to} ${colors.dim(`moved because ${c.reason}`)}`,
);
for (const blocker of row.p.companionBlockers ?? [])
lines.push(
` ${colors.red('✗')} blocked: ${blocker} ${colors.dim('a new major, never made by Uptide')}`,
);
for (const conflict of row.p.companionConflicts ?? [])
lines.push(` ${colors.red('✗')} ${conflict}: no consistent upgrade`);
for (const stay of row.p.companionStays ?? []) lines.push(` ${colors.dim(`· ${stay}`)}`);
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ export function formatPackTest(report: PackTestReport, color = false): string {
);
if (r.installedMismatch) out.push(` ${c.yellow('!')} ${r.installedMismatch}`);
if (r.companions?.length)
out.push(` with ${r.companions.map((m) => `${m.name} ${m.from} → ${m.to}`).join(', ')}`);
out.push(
` with ${r.companions.map((m) => `${m.name} ${m.from} → ${m.to}${m.required ? ` (moved because ${m.reason})` : ''}`).join(', ')}`,
);
for (const stay of r.stays ?? []) out.push(` ${c.dim(`· ${stay}`)}`);
for (const blocker of r.blockers ?? []) out.push(` ${c.red('✗')} blocked: ${blocker}`);
for (const left of r.leftBehind ?? []) out.push(` ${c.red('✗')} ${left}`);
}
out.push('');
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/check/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,8 @@ export function mergeAcrossWorkspaces(
if (conflicts.length > 0) combined.companionConflicts = conflicts;
const stays = [...new Set(list.flatMap((p) => p.companionStays ?? []))].sort();
if (stays.length > 0) combined.companionStays = stays;
const blockers = [...new Set(list.flatMap((p) => p.companionBlockers ?? []))].sort();
if (blockers.length > 0) combined.companionBlockers = blockers;
const peerConflicts = [...new Set(list.flatMap((p) => p.peerConflicts ?? []))].sort();
if (peerConflicts.length > 0) combined.peerConflicts = peerConflicts;
const members = [
Expand Down Expand Up @@ -1251,7 +1253,8 @@ async function companionPlans(
plan.companions.length > 0 ||
plan.conflicts.length > 0 ||
plan.peerConflicts.length > 0 ||
plan.stays?.length
plan.stays?.length ||
plan.blockers?.length
)
plans[lead] = plan;
} catch {
Expand Down Expand Up @@ -1761,6 +1764,7 @@ async function checkGroup(
...(plan?.companions.length ? { companions: plan.companions } : {}),
...(plan?.conflicts.length ? { companionConflicts: plan.conflicts } : {}),
...(plan?.stays?.length ? { companionStays: plan.stays } : {}),
...(plan?.blockers?.length ? { companionBlockers: plan.blockers } : {}),
...(plan?.peerConflicts.length ? { peerConflicts: plan.peerConflicts } : {}),
majorsBehind: Math.max(...prepared.map((p) => majorsBehind(p.installedVersion, p.target))),
findings: [],
Expand Down
51 changes: 45 additions & 6 deletions packages/core/src/check/companions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { satisfies } from '../fetch/range.js';
import type { Manifest } from '../list/evidence.js';
import { dependencyGroups } from '../list/groups.js';
import type { ListedDependency } from '../list/list.js';
import { requiredMoves } from './required.js';
import { typedPackageOf, typesReleaseFor } from './types-release.js';
import { compareVersions, parseVersion } from './version.js';

Expand All @@ -12,6 +13,11 @@ export interface Companion {
to: string;
/** Why this version: `ai 7.0.9 pins @ai-sdk/provider 4.0.1`. */
reason: string;
/**
* Moved because a planned version requires it, not because it is released with the lead:
* `openai 7.4.0 requires zod ^3.25 || ^4.0` (`required.ts`). Always within its major.
*/
required?: true;
}

export interface InstalledDependency {
Expand All @@ -37,6 +43,11 @@ export interface CompanionPlan {
* the pack declares. Information, never a reason to refuse the upgrade.
*/
stays?: string[];
/**
* Moves a planned version requires that only a new major makes, never made: `openai 7.9.0
* needs zod ^4.0.0; installed 3.25.76, and only a new major satisfies it (zod 4.0.0)`.
*/
blockers?: string[];
/** The group as `list` names it (`@ai-sdk family, shared @ai-sdk/provider-utils`). */
reason?: string;
}
Expand Down Expand Up @@ -218,11 +229,7 @@ function typesMove(
return { version, reason: `${dep.name} ${version} types ${host} ${hostTarget}` };
}

/**
* What moves with `name@target` in a repository, from its installed dependencies and the
* registry's manifests of every version (`manifests`, one cached packument per package).
*/
export async function companionsOf(input: {
interface CompanionInput {
name: string;
target: string;
installed: InstalledDependency[];
Expand All @@ -232,7 +239,39 @@ export async function companionsOf(input: {
releaseGroup?: readonly string[];
/** Targets already planned before an explicitly requested peer upgrade. */
peerTargets?: Record<string, string>;
}): Promise<CompanionPlan> {
}

/**
* What moves with `name@target` in a repository, from its installed dependencies and the
* registry's manifests of every version (`manifests`, one cached packument per package): its
* release group, then what the planned versions require of the rest (`required.ts`).
*/
export async function companionsOf(input: CompanionInput): Promise<CompanionPlan> {
const plan = await releaseGroupOf(input);
const lead = input.installed.find((d) => d.name === input.name);
const targetManifest = lead && (await input.manifests(input.name))[input.target];
if (!lead || !targetManifest) return plan;
const first = (line: string) => line.split(' ')[0] as string;
const required = await requiredMoves({
lead,
target: input.target,
targetManifest,
installed: input.installed,
manifests: input.manifests,
planned: plan.companions,
reported: new Set([...plan.conflicts, ...plan.peerConflicts, ...(plan.stays ?? [])].map(first)),
});
if (!required.moves.length && !required.blockers.length) return plan;
return {
...plan,
companions: [...plan.companions, ...required.moves].sort((a, b) =>
a.name.localeCompare(b.name),
),
...(required.blockers.length ? { blockers: required.blockers } : {}),
};
}

async function releaseGroupOf(input: CompanionInput): Promise<CompanionPlan> {
const none: CompanionPlan = { companions: [], conflicts: [], peerConflicts: [] };
const lead = input.installed.find((d) => d.name === input.name);
if (!lead) return none;
Expand Down
Loading
Loading