diff --git a/.changeset/required-moves.md b/.changeset/required-moves.md new file mode 100644 index 0000000..14600d8 --- /dev/null +++ b/.changeset/required-moves.md @@ -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. diff --git a/README.md b/README.md index 8cb30df..49ba608 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 629103c..9a2ff8b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/packs.md b/docs/packs.md index 1ad07b7..bcb1682 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -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`. diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index d462c95..0b9583d 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -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([ diff --git a/packages/cli/src/format-check.ts b/packages/cli/src/format-check.ts index 274c022..84eb996 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -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}`)}`); diff --git a/packages/cli/src/pack.ts b/packages/cli/src/pack.ts index 6977a0b..b8b54a8 100644 --- a/packages/cli/src/pack.ts +++ b/packages/cli/src/pack.ts @@ -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(''); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index 4c16f41..d97111f 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -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 = [ @@ -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 { @@ -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: [], diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts index c2b4162..d962a5b 100644 --- a/packages/core/src/check/companions.ts +++ b/packages/core/src/check/companions.ts @@ -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'; @@ -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 { @@ -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; } @@ -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[]; @@ -232,7 +239,39 @@ export async function companionsOf(input: { releaseGroup?: readonly string[]; /** Targets already planned before an explicitly requested peer upgrade. */ peerTargets?: Record; -}): Promise { +} + +/** + * 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 { + 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 { const none: CompanionPlan = { companions: [], conflicts: [], peerConflicts: [] }; const lead = input.installed.find((d) => d.name === input.name); if (!lead) return none; diff --git a/packages/core/src/check/required.test.ts b/packages/core/src/check/required.test.ts new file mode 100644 index 0000000..4fee371 --- /dev/null +++ b/packages/core/src/check/required.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest'; +import { companionsOf, type InstalledDependency } from './companions.js'; + +type Versions = Record< + string, + { + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + } +>; + +// openai as its 4.x and 7.x releases declare zod: an optional peer, `^3.25 || ^4.0` from 7. +const openai: Versions = { + '4.83.0': { + peerDependencies: { zod: '^3.23.8' }, + peerDependenciesMeta: { zod: { optional: true } }, + }, + '4.104.0': { + peerDependencies: { zod: '^3.23.8' }, + peerDependenciesMeta: { zod: { optional: true } }, + }, + '7.4.0': { + peerDependencies: { zod: '^3.25 || ^4.0' }, + peerDependenciesMeta: { zod: { optional: true } }, + }, + '7.9.0': { + peerDependencies: { zod: '^3.25 || ^4.0' }, + peerDependenciesMeta: { zod: { optional: true } }, + }, +}; +const zod: Versions = { + '3.24.1': {}, + '3.24.2': {}, + '3.25.0-canary.20250504T221648': {}, + '3.25.0': {}, + '3.25.40': {}, + '3.25.76': {}, + '4.0.0': {}, + '4.4.3': {}, + '4.5.4': {}, +}; +// @openai/agents is on its own 0.x line; 0.15 is its first release on openai 7, and needs zod 4. +const agents: Versions = { + '0.1.11': { dependencies: { openai: '^5.20.2' }, peerDependencies: { zod: '^3.25.40' } }, + '0.14.3': { dependencies: { openai: '^6.46.0' }, peerDependencies: { zod: '^4.0.0' } }, + '0.15.0': { dependencies: { openai: '^7.2.0' }, peerDependencies: { zod: '^4.0.0' } }, + '0.17.2': { dependencies: { openai: '^7.2.0' }, peerDependencies: { zod: '^4.0.0' } }, +}; + +const registryOf = + (registry: Record) => + async (name: string): Promise => + registry[name] ?? {}; +const at = ( + registry: Record, + name: string, + version: string, + workspaces = ['.'], +): InstalledDependency => ({ + name, + version, + manifest: { name, version, ...registry[name]?.[version] }, + workspaces, +}); + +const batchAi = { openai, zod }; +const guardrails = { openai, zod, '@openai/agents': agents }; + +describe('what the target requires', () => { + it('moves a peer the target requires to the lowest release of its major that satisfies it (batch-ai)', async () => { + // grantsingleton/batch-ai: openai 4.83.0 → 7.4.0 with zod 3.24.2, below openai 7's zod peer. + const plan = await companionsOf({ + name: 'openai', + target: '7.4.0', + installed: [at(batchAi, 'openai', '4.83.0'), at(batchAi, 'zod', '3.24.2')], + manifests: registryOf(batchAi), + }); + expect(plan.companions).toEqual([ + { + name: 'zod', + from: '3.24.2', + to: '3.25.0', + reason: 'openai 7.4.0 requires zod ^3.25 || ^4.0', + required: true, + }, + ]); + expect(plan.blockers ?? []).toEqual([]); + expect(plan.conflicts).toEqual([]); + }); + + it('never moves a package that already satisfies the target, or one that is not installed', async () => { + const satisfied = await companionsOf({ + name: 'openai', + target: '7.4.0', + installed: [at(batchAi, 'openai', '4.104.0'), at(batchAi, 'zod', '3.25.76')], + manifests: registryOf(batchAi), + }); + expect(satisfied.companions).toEqual([]); + expect(satisfied.blockers ?? []).toEqual([]); + const absent = await companionsOf({ + name: 'openai', + target: '7.4.0', + installed: [at(batchAi, 'openai', '4.104.0')], + manifests: registryOf(batchAi), + }); + expect(absent).toEqual({ companions: [], conflicts: [], peerConflicts: [] }); + }); + + it('reports a peer that only a new major satisfies as a blocker, with the exact range, and does not move it', async () => { + const registry = { + schema: { + '1.0.0': { peerDependencies: { zod: '^3.23.8' } }, + '2.0.0': { peerDependencies: { zod: '^4.0.0' } }, + }, + zod, + }; + const plan = await companionsOf({ + name: 'schema', + target: '2.0.0', + installed: [at(registry, 'schema', '1.0.0'), at(registry, 'zod', '3.25.76')], + manifests: registryOf(registry), + }); + expect(plan.companions).toEqual([]); + expect(plan.blockers).toEqual([ + 'schema 2.0.0 needs zod ^4.0.0; installed 3.25.76, and only a new major satisfies it (zod 4.0.0)', + ]); + }); + + it('treats a 0.x minor as a new major: 0.1.4 never moves to 0.2.0 on its own', async () => { + const registry = { + lead: { + '1.0.0': { peerDependencies: { tiny: '^0.1.0' } }, + '2.0.0': { peerDependencies: { tiny: '^0.2.0' } }, + }, + tiny: { '0.1.4': {}, '0.1.9': {}, '0.2.0': {} }, + }; + const plan = await companionsOf({ + name: 'lead', + target: '2.0.0', + installed: [at(registry, 'lead', '1.0.0'), at(registry, 'tiny', '0.1.4')], + manifests: registryOf(registry), + }); + expect(plan.companions).toEqual([]); + expect(plan.blockers).toEqual([ + 'lead 2.0.0 needs tiny ^0.2.0; installed 0.1.4, and only a new major satisfies it (tiny 0.2.0)', + ]); + }); + + it('moves a plain dependency within its major, and leaves one that needs a new major to a nested copy', async () => { + const registry = { + lead: { + '1.0.0': { dependencies: { helper: '^1.0.0', other: '^1.0.0' } }, + '2.0.0': { dependencies: { helper: '^1.4.0', other: '^2.0.0' } }, + }, + helper: { '1.2.0': {}, '1.4.0': {}, '1.5.0': {} }, + other: { '1.0.0': {}, '2.0.0': {} }, + }; + const plan = await companionsOf({ + name: 'lead', + target: '2.0.0', + installed: [ + at(registry, 'lead', '1.0.0'), + at(registry, 'helper', '1.2.0'), + at(registry, 'other', '1.0.0'), + ], + manifests: registryOf(registry), + }); + expect(plan.companions).toEqual([ + { + name: 'helper', + from: '1.2.0', + to: '1.4.0', + reason: 'lead 2.0.0 requires helper ^1.4.0', + required: true, + }, + ]); + // The package manager installs other 2.0.0 for lead alone; the repository's 1.0.0 stays. + expect(plan.blockers ?? []).toEqual([]); + }); + + it('only plans what a workspace of the lead resolves', async () => { + const plan = await companionsOf({ + name: 'openai', + target: '7.4.0', + installed: [ + at(batchAi, 'openai', '4.83.0', ['packages/api']), + at(batchAi, 'zod', '3.24.2', ['packages/web']), + ], + manifests: registryOf(batchAi), + }); + expect(plan.companions).toEqual([]); + expect(plan.blockers ?? []).toEqual([]); + const hoisted = await companionsOf({ + name: 'openai', + target: '7.4.0', + installed: [ + at(batchAi, 'openai', '4.83.0', ['packages/api']), + at(batchAi, 'zod', '3.24.2', ['.']), + ], + manifests: registryOf(batchAi), + }); + expect(hoisted.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['zod 3.25.0']); + }); + + it('reports a dependent that needs a new 0.x line, and what that line would need (openai-guardrails-js)', async () => { + // openai/openai-guardrails-js: @openai/agents 0.1.11 depends on openai ^5.20.2. Its first + // release on openai 7 is 0.15.0, a new line, which needs zod 4; zod 3.25.76 is installed. + // The real upgrade moved both across their majors; Uptide reports them and moves neither. + const plan = await companionsOf({ + name: 'openai', + target: '7.9.0', + installed: [ + at(guardrails, 'openai', '4.104.0'), + at(guardrails, '@openai/agents', '0.1.11'), + at(guardrails, 'zod', '3.25.76'), + ], + manifests: registryOf(guardrails), + }); + expect(plan.companions).toEqual([]); + expect(plan.blockers).toEqual([ + '@openai/agents 0.1.11 depends on openai ^5.20.2; only a new major accepts openai 7.9.0 (@openai/agents 0.15.0, which needs zod ^4.0.0; installed 3.25.76)', + ]); + }); + + it('moves a dependent within its line when a release there accepts the target, and checks what that release requires', async () => { + const registry = { + ...guardrails, + '@openai/agents': { + ...agents, + '0.1.12': { dependencies: { openai: '^7.0.0' }, peerDependencies: { zod: '^3.25.60' } }, + }, + }; + const plan = await companionsOf({ + name: 'openai', + target: '7.9.0', + installed: [ + at(registry, 'openai', '4.104.0'), + at(registry, '@openai/agents', '0.1.11'), + at(registry, 'zod', '3.25.40'), + ], + manifests: registryOf(registry), + }); + expect(plan.companions).toEqual([ + { + name: '@openai/agents', + from: '0.1.11', + to: '0.1.12', + reason: '@openai/agents 0.1.11 depends on openai ^5.20.2, which rejects openai 7.9.0', + required: true, + }, + { + name: 'zod', + from: '3.25.40', + to: '3.25.76', + reason: '@openai/agents 0.1.12 requires zod ^3.25.60', + required: true, + }, + ]); + expect(plan.blockers ?? []).toEqual([]); + }); + + it('keeps release-group companions as they are and adds the required moves next to them', async () => { + // companions.test.ts's AI SDK 6 → 7 registry, with ai 7's zod peer. + const registry: Record = { + ai: { + '6.0.116': { + dependencies: { '@ai-sdk/provider': '3.0.3', '@ai-sdk/provider-utils': '4.0.5' }, + }, + '7.0.9': { + dependencies: { '@ai-sdk/provider': '4.0.1', '@ai-sdk/provider-utils': '5.0.1' }, + peerDependencies: { zod: '^3.25.76 || ^4.1.8' }, + }, + }, + '@ai-sdk/provider': { '3.0.3': {}, '4.0.1': {}, '4.0.2': {} }, + '@ai-sdk/react': { + '3.0.118': { dependencies: { ai: '6.0.116', '@ai-sdk/provider-utils': '4.0.5' } }, + '4.0.10': { dependencies: { ai: '7.0.9', '@ai-sdk/provider-utils': '5.0.1' } }, + }, + zod: { '3.25.0': {}, '3.25.76': {}, '4.1.8': {} }, + }; + const plan = await companionsOf({ + name: 'ai', + target: '7.0.9', + installed: [ + at(registry, 'ai', '6.0.116'), + at(registry, '@ai-sdk/provider', '3.0.3'), + at(registry, '@ai-sdk/react', '3.0.118'), + at(registry, 'zod', '3.25.0'), + ], + manifests: registryOf(registry), + }); + const group = plan.companions.filter((c) => c.name.startsWith('@ai-sdk/')); + expect(group.map((c) => `${c.name} ${c.to}`)).toEqual([ + '@ai-sdk/provider 4.0.1', + '@ai-sdk/react 4.0.10', + ]); + expect(group.every((c) => c.required === undefined)).toBe(true); + expect(plan.companions).toEqual([ + ...group, + { + name: 'zod', + from: '3.25.0', + to: '3.25.76', + reason: 'ai 7.0.9 requires zod ^3.25.76 || ^4.1.8', + required: true, + }, + ]); + }); + + it('leaves a requirement the install already breaks today, and what depends on it (miurla/morphic)', async () => { + // @ai-sdk/react 3.0.182 already peers on react ~19.1.2 || ^19.2.1 and react 19.2.0 is + // installed: the upgrade does not introduce that, so react stays, and so does + // @supabase/auth-ui-react, whose react ^18.2.0 rejects 19.x before and after. + const peers = { react: '^18 || ~19.0.1 || ~19.1.2 || ^19.2.1' }; + const registry: Record = { + ai: { '6.0.180': {}, '7.0.66': {} }, + '@ai-sdk/react': { + '3.0.182': { dependencies: { ai: '6.0.180' }, peerDependencies: peers }, + '4.0.69': { dependencies: { ai: '7.0.66' }, peerDependencies: peers }, + }, + react: { '19.2.0': {}, '19.2.1': {} }, + '@supabase/auth-ui-react': { '0.4.7': { dependencies: { react: '^18.2.0' } } }, + }; + const plan = await companionsOf({ + name: 'ai', + target: '7.0.66', + installed: [ + at(registry, 'ai', '6.0.180'), + at(registry, '@ai-sdk/react', '3.0.182'), + at(registry, 'react', '19.2.0'), + at(registry, '@supabase/auth-ui-react', '0.4.7'), + ], + manifests: registryOf(registry), + releaseGroup: ['@ai-sdk/react'], + }); + expect(plan.companions.map((c) => `${c.name} ${c.to}`)).toEqual(['@ai-sdk/react 4.0.69']); + expect(plan.blockers ?? []).toEqual([]); + }); +}); diff --git a/packages/core/src/check/required.ts b/packages/core/src/check/required.ts new file mode 100644 index 0000000..8114710 --- /dev/null +++ b/packages/core/src/check/required.ts @@ -0,0 +1,179 @@ +import { satisfies, validRange } from '../fetch/range.js'; +import type { Manifest } from '../list/evidence.js'; +import type { Companion, InstalledDependency } from './companions.js'; +import { compareVersions, parseVersion } from './version.js'; + +type Manifests = Record>; + +/** A move the target version requires, as opposed to one its release group makes. */ +export interface RequiredPlan { + moves: Companion[]; + /** + * Required moves 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[]; +} + +const stable = (version: string): boolean => { + const parsed = parseVersion(version); + return parsed !== undefined && parsed.pre === undefined; +}; +/** A range a registry would resolve: `workspace:`, `npm:` aliases and git URLs say nothing here. */ +const semverRange = (range: string | undefined): range is string => { + try { + return range !== undefined && validRange(range); + } catch { + return false; + } +}; +const accepts = (range: string, version: string): boolean => { + try { + return satisfies(version, range); + } catch { + return false; + } +}; +/** + * The releases a caret range of `installed` reaches: one major, and for 0.x one minor + * (`^0.1.11` is `<0.2.0`), the line semver promises compatible. Anything past it is a new major. + */ +const sameLine = (installed: string, version: string): boolean => { + const a = parseVersion(installed); + const b = parseVersion(version); + if (!a || !b || a.major !== b.major) return false; + if (a.major > 0) return true; + return a.minor === b.minor && (a.minor > 0 || a.patch === b.patch); +}; +/** Stable releases above `installed`, lowest first. */ +const newer = (versions: Manifests, installed: string): string[] => + Object.keys(versions) + .filter((v) => stable(v) && compareVersions(v, installed) >= 0) + .sort(compareVersions); + +/** Whether a workspace of `dep` serves a workspace of `host`: the same one, or an ancestor. */ +const visibleFrom = (dep: InstalledDependency, host: InstalledDependency): boolean => + dep.workspaces.some((d) => + host.workspaces.some((h) => h === d || d === '.' || h.startsWith(`${d}/`)), + ); + +/** + * What the planned versions require of the rest of the install, beyond their release group + * (`companions.ts`): a package the lead's target, or any package the plan moves, declares as a + * peer or dependency at a range the installed copy does not satisfy, when the installed host + * accepted it; and a direct dependency whose own dependency range rejects the lead's target. + * Each moves to the lowest release of its + * line that agrees. One that only a new major satisfies is never moved: a peer or a dependent + * is a blocker, with the exact range; a plain dependency gets its own nested copy, so the + * repository's stays. + */ +export async function requiredMoves(input: { + lead: InstalledDependency; + target: string; + targetManifest: Manifests[string]; + installed: InstalledDependency[]; + manifests: (name: string) => Promise; + /** What the release-group plan already decided, moved or not: never planned twice. */ + planned: readonly Companion[]; + /** Packages the plan reports on (conflicts, peer conflicts, stays): left to that report. */ + reported: ReadonlySet; +}): Promise { + const { lead } = input; + const decided = new Map([ + [lead.name, { version: input.target, manifest: input.targetManifest }], + ]); + for (const c of input.planned) + decided.set(c.name, { version: c.to, manifest: (await input.manifests(c.name))[c.to] ?? {} }); + const moves: Companion[] = []; + let blockers = new Set(); + const open = (d: InstalledDependency) => + d.name !== lead.name && !decided.has(d.name) && !input.reported.has(d.name); + const move = async (d: InstalledDependency, to: string, reason: string) => { + moves.push({ name: d.name, from: d.version, to, reason, required: true }); + decided.set(d.name, { version: to, manifest: (await input.manifests(d.name))[to] ?? {} }); + }; + /** `zod ^4.0.0; installed 3.25.76` for each peer of `manifest` the install rejects. */ + const unmetPeers = (manifest: Manifests[string]): string[] => + Object.entries(manifest.peerDependencies ?? {}).flatMap(([name, range]) => { + if (!semverRange(range)) return []; + const planned = decided.get(name)?.version; + const dep = input.installed.find((d) => d.name === name && visibleFrom(d, lead)); + const version = planned ?? dep?.version; + return version === undefined || accepts(range, version) + ? [] + : [`${name} ${range}; ${planned ? 'planned' : 'installed'} ${version}`]; + }); + + // Until nothing new: a package moved for one requirement can declare the next. + // The last pass, which moved nothing, is the one whose blockers stand. + for (let grew = true; grew; ) { + grew = false; + blockers = new Set(); + // What a planned version declares, and what the install has. + for (const [host, at] of [...decided]) { + for (const peer of [true, false]) { + const ranges = (peer ? at.manifest.peerDependencies : at.manifest.dependencies) ?? {}; + for (const [name, range] of Object.entries(ranges)) { + if (!semverRange(range)) continue; + const dep = input.installed.find( + (d) => d.name === name && open(d) && visibleFrom(d, lead), + ); + if (!dep || accepts(range, dep.version)) continue; + // Broken already by the installed host (morphic's react 19.2.0 under @ai-sdk/react 3's + // `~19.1.2 || ^19.2.1`): the repository lives with it today, so it is not this upgrade's. + const today = input.installed.find((d) => d.name === host && visibleFrom(d, lead)); + const before = (peer ? today?.manifest.peerDependencies : today?.manifest.dependencies)?.[ + name + ]; + if (semverRange(before) && !accepts(before, dep.version)) continue; + const versions = await input.manifests(name); + const candidates = newer(versions, dep.version).filter((v) => accepts(range, v)); + const within = candidates.find((v) => sameLine(dep.version, v)); + if (within) { + await move(dep, within, `${host} ${at.version} requires ${name} ${range}`); + grew = true; + } else if (peer) + blockers.add( + `${host} ${at.version} needs ${name} ${range}; installed ${dep.version}, and ${candidates[0] ? `only a new major satisfies it (${name} ${candidates[0]})` : 'no release satisfies it'}`, + ); + } + } + } + // A direct dependency whose own dependency range rejects the lead's target: the package + // the user asked to move, which that dependency would keep a second copy of. + for (const dep of input.installed) { + if (!open(dep) || !dep.workspaces.some((w) => lead.workspaces.includes(w))) continue; + for (const [host, at] of [[lead.name, decided.get(lead.name)]] as const) { + if (!at) continue; + const range = dep.manifest.dependencies?.[host]; + if (!semverRange(range) || accepts(range, at.version)) continue; + const versions = await input.manifests(dep.name); + const agrees = (v: string) => { + const theirs = versions[v]?.dependencies?.[host] ?? versions[v]?.peerDependencies?.[host]; + return theirs === undefined || accepts(theirs, at.version); + }; + const candidates = newer(versions, dep.version).filter(agrees); + const within = candidates.find((v) => sameLine(dep.version, v)); + if (within) { + await move( + dep, + within, + `${dep.name} ${dep.version} depends on ${host} ${range}, which rejects ${host} ${at.version}`, + ); + grew = true; + break; + } + const first = candidates[0]; + const needs = first ? unmetPeers(versions[first] ?? {}) : []; + blockers.add( + `${dep.name} ${dep.version} depends on ${host} ${range}; ${ + first + ? `only a new major accepts ${host} ${at.version} (${dep.name} ${first}${needs.length ? `, which needs ${needs.join(', and ')}` : ''})` + : `no release accepts ${host} ${at.version}` + }`, + ); + } + } + } + return { moves, blockers: [...blockers].sort() }; +} diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index e8e14f4..62ea301 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -153,9 +153,14 @@ export interface PackageReport { * What moves with the package `check` was asked about (`check/companions.ts`): its group at * the versions that agree with its target, each with why; `fix` upgrades them together. */ - companions?: { name: string; from: string; to: string; reason: string }[]; + companions?: { name: string; from: string; to: string; reason: string; required?: true }[]; /** Members of that group with no release that agrees with the target. */ companionConflicts?: string[]; + /** + * Moves the target requires that only a new major makes, never made (`check/required.ts`): + * `openai 7.9.0 needs zod ^4.0.0; installed 3.25.76, and only a new major satisfies it (zod 4.0.0)`. + */ + companionBlockers?: string[]; /** * Packages of the same scope that stay where they are, released on their own or outside the * pack's release group: `@sentry/vite-plugin stays at 5.4.0 (...)`. Never a conflict. diff --git a/packages/core/src/fix/generic.test.ts b/packages/core/src/fix/generic.test.ts index 9001d7a..a3e4cc5 100644 --- a/packages/core/src/fix/generic.test.ts +++ b/packages/core/src/fix/generic.test.ts @@ -503,6 +503,47 @@ describe('fix with companions', () => { ); }, 20000); + it('installs what the target requires with it, and names why apart from its companions', async () => { + const { root, report, services, fixer, installs } = withBrush(); + const lead = report.packages[0] as CheckReport['packages'][number]; + lead.companions = [ + { + name: 'brush', + from: '1.0.0', + to: '1.4.0', + reason: 'paint 2.0.0 requires brush ^1.4.0', + required: true, + }, + ]; + const result = await fix({ cwd: root, only: 'paint', fixer: fixer(0.01) }, services); + // The install scope is the lead and the required move, one install. + expect(installs[0]).toMatchObject({ also: [{ name: 'brush', version: '1.4.0' }] }); + const body = prBody(result); + expect(body).toContain( + 'Moved because the new version requires it:\n\n- `brush` 1.0.0 → 1.4.0 (moved because paint 2.0.0 requires brush ^1.4.0)', + ); + expect(body).not.toContain('Upgraded with paint'); + }, 20000); + + it('names a required move it will not make, a new major, as a blocker in the PR body', async () => { + const { root, report, services, fixer, installs } = withBrush(); + const lead = report.packages[0] as CheckReport['packages'][number]; + delete lead.companions; + lead.companionBlockers = [ + 'paint 2.0.0 needs brush ^2.0.0; installed 1.0.0, and only a new major satisfies it (brush 2.0.0)', + ]; + const result = await fix({ cwd: root, only: 'paint', fixer: fixer(0.01) }, services); + expect(installs[0]?.also).toBeUndefined(); + expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).dependencies.brush).toBe( + '1.0.0', + ); + const body = prBody(result); + expect(body).toContain( + 'Blocked: a required move only a new major makes, which Uptide never makes:\n\n- paint 2.0.0 needs brush ^2.0.0; installed 1.0.0, and only a new major satisfies it (brush 2.0.0)', + ); + expect(body).toContain('a required move is blocked'); + }, 20000); + it('changes nothing when a companion has no version that agrees', async () => { const { root, services, fixer, installs } = withBrush([ 'brush 1.0.0 has no release that agrees with paint 2.0.0', diff --git a/packages/core/src/fix/managers/lock-guard.ts b/packages/core/src/fix/managers/lock-guard.ts index 78f304c..4490151 100644 --- a/packages/core/src/fix/managers/lock-guard.ts +++ b/packages/core/src/fix/managers/lock-guard.ts @@ -236,6 +236,7 @@ export function assertLockScope( left: Record, right: Record, seen = new Set(), + declared = false, ): boolean => [...new Set([...Object.keys(left), ...Object.keys(right)])].every((name) => { if (!Object.hasOwn(left, name) || !Object.hasOwn(right, name)) return false; @@ -243,7 +244,10 @@ export function assertLockScope( b = right[name]; if (a === undefined || b === undefined) return a === b; // Intended upgrades stay authorized; dedupe from an outside copy must still compare. - if (originalScope.has(a) && originalScope.has(b)) return true; + // A package an importer declares moves only when the plan names it: npm resolves a + // moved package's peer to the root copy, and reaching it is no consent to move it. + if (originalScope.has(a) && originalScope.has(b) && (!declared || names.includes(name))) + return true; // Admitted peers may move; their transitive upgrades cannot change an outside consumer. if (extraRoots.includes(a) && extraRoots.includes(b)) return true; return equivalent(a, b, seen); @@ -252,7 +256,7 @@ export function assertLockScope( ...Object.keys(before.roots ?? {}), ...Object.keys(after.roots ?? {}), ])) - if (!sameEdges(before.roots?.[key] ?? {}, after.roots?.[key] ?? {})) + if (!sameEdges(before.roots?.[key] ?? {}, after.roots?.[key] ?? {}, new Set(), true)) unexpected.add(`importer ${key || '.'} resolutions`); const counterpart = ( diff --git a/packages/core/src/fix/managers/required-scope.test.ts b/packages/core/src/fix/managers/required-scope.test.ts new file mode 100644 index 0000000..12b5260 --- /dev/null +++ b/packages/core/src/fix/managers/required-scope.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { assertLockScope, type LockGraph, type Targets } from './lock-guard.js'; +import { npmGraph } from './npm-lock.js'; +import { pnpmGraph, yarnGraph } from './text-lock.js'; + +// batch-ai's shape: openai and zod declared at the root, zod an optional peer of openai. +// The plan moves openai 4.83.0 → 7.4.0 and zod 3.24.2 → 3.25.0 because openai 7 requires it. +interface Versions { + openai: string; + zod: string; + other?: string; +} +const npm = ({ openai, zod, other = '1.0.0' }: Versions) => + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { dependencies: { openai: '^4.83.0', other: '^1.0.0', zod: '^3.24.1' } }, + 'node_modules/openai': { + version: openai, + integrity: `sha512-openai-${openai}`, + peerDependencies: { zod: openai.startsWith('7') ? '^3.25 || ^4.0' : '^3.23.8' }, + peerDependenciesMeta: { zod: { optional: true } }, + }, + 'node_modules/other': { version: other, integrity: `sha512-other-${other}` }, + 'node_modules/zod': { version: zod, integrity: `sha512-zod-${zod}` }, + }, + }); +const pnpm = ({ openai, zod, other = '1.0.0' }: Versions) => `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + openai: + specifier: ^4.83.0 + version: ${openai}(zod@${zod}) + other: + specifier: ^1.0.0 + version: ${other} + zod: + specifier: ^3.24.1 + version: ${zod} + +packages: + + openai@${openai}: + resolution: {integrity: sha512-openai-${openai}} + peerDependencies: + zod: ${openai.startsWith('7') ? "'^3.25 || ^4.0'" : '^3.23.8'} + peerDependenciesMeta: + zod: + optional: true + + other@${other}: + resolution: {integrity: sha512-other-${other}} + + zod@${zod}: + resolution: {integrity: sha512-zod-${zod}} + +snapshots: + + openai@${openai}(zod@${zod}): + optionalDependencies: + zod: ${zod} + + other@${other}: {} + + zod@${zod}: {} +`; +const yarn = ({ + openai, + zod, + other = '1.0.0', +}: Versions) => `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +openai@^4.83.0: + version "${openai}" + resolved "https://registry.yarnpkg.com/openai/-/openai-${openai}.tgz" + integrity sha512-openai-${openai} + +other@^1.0.0: + version "${other}" + resolved "https://registry.yarnpkg.com/other/-/other-${other}.tgz" + integrity sha512-other-${other} + +zod@^3.24.1: + version "${zod}" + resolved "https://registry.yarnpkg.com/zod/-/zod-${zod}.tgz" + integrity sha512-zod-${zod} +`; + +const managers: [string, (text: string, target: Targets) => LockGraph, (v: Versions) => string][] = + [ + ['npm', npmGraph, npm], + ['pnpm', pnpmGraph, pnpm], + ['yarn', yarnGraph, yarn], + ]; +const before = { openai: '4.83.0', zod: '3.24.2' }; +const planned = ['openai', 'zod']; + +describe.each(managers)('%s: the install scope guard and required moves', (_, graph, lock) => { + const guard = (after: Versions, targets: Targets) => + assertLockScope(graph(lock(before), targets), graph(lock(after), targets), targets); + + it('allows the lead and the package its target requires to move together', () => { + expect(() => guard({ openai: '7.4.0', zod: '3.25.0' }, planned)).not.toThrow(); + }); + + it('refuses a move of the required package the plan did not make', () => { + // The lead alone was planned (zod already satisfied it): zod moving anyway is out of scope, + // though npm and pnpm resolve it as the lead's peer. + expect(() => guard({ openai: '7.4.0', zod: '3.25.0' }, 'openai')).toThrow( + /outside openai's dependency subtree/, + ); + }); + + it('still refuses anything else that moves next to the planned moves', () => { + expect(() => guard({ openai: '7.4.0', zod: '3.25.0', other: '1.1.0' }, planned)).toThrow( + /outside openai, zod's dependency subtree/, + ); + }); +}); diff --git a/packages/core/src/fix/report.test.ts b/packages/core/src/fix/report.test.ts index dd520cc..76c4aa8 100644 --- a/packages/core/src/fix/report.test.ts +++ b/packages/core/src/fix/report.test.ts @@ -1007,6 +1007,31 @@ it('says why a required build failed when it stopped before the framework build' 'the build needs DATABASE_URL before the framework build started. Set `verify.build` in uptide.yml', ); }); +it('lists required moves apart from companions, and blockers, next to what stays', () => { + const report = ruleOnly(); + report.companions = [ + { + name: 'zod', + from: '3.24.2', + to: '3.25.0', + reason: 'openai 7.4.0 requires zod ^3.25 || ^4.0', + required: true, + }, + ]; + report.companionBlockers = [ + '@openai/agents 0.1.11 depends on openai ^5.20.2; only a new major accepts openai 7.9.0 (@openai/agents 0.15.0, which needs zod ^4.0.0; installed 3.25.76)', + ]; + const body = visible(prBody(report)); + expect(body).not.toContain('Upgraded with'); + expect(body).toContain( + '- `zod` 3.24.2 → 3.25.0 (moved because openai 7.4.0 requires zod ^3.25 || ^4.0)', + ); + expect(body).toContain( + '- @openai/agents 0.1.11 depends on openai ^5.20.2; only a new major accepts openai 7.9.0', + ); + expect(migrationRisk(report)).toMatchObject({ level: 'High' }); + expect(migrationRisk(report).reason).toContain('a required move is blocked'); +}); it('names the packages of the scope that stay where they are', () => { const report = ruleOnly(); report.companionStays = [ diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index 0acc483..77855ee 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -317,6 +317,7 @@ export function migrationRisk(report: FixReport): { const high = [ ...(report.packWarning ? [report.packWarning] : []), ...(report.peerConflicts?.length ? ['peer compatibility is not verified'] : []), + ...(report.companionBlockers?.length ? ['a required move is blocked'] : []), ...(report.sites.some((s) => s.outcome === 'manual') ? ['manual sites left'] : []), ...(!report.verification.passed || report.verification.newErrors.length || @@ -733,11 +734,27 @@ export function renderMigration( ); } // What moved with the package: one install at versions that agree, never the package alone. - if (report.companions?.length) + const released = report.companions?.filter((c) => !c.required) ?? []; + const required = report.companions?.filter((c) => c.required) ?? []; + if (released.length) lines.push( `Upgraded with ${report.package}, so the install stays consistent:`, '', - ...report.companions.map((c) => `- \`${c.name}\` ${c.from} → ${c.to} (${c.reason})`), + ...released.map((c) => `- \`${c.name}\` ${c.from} → ${c.to} (${c.reason})`), + '', + ); + if (required.length) + lines.push( + 'Moved because the new version requires it:', + '', + ...required.map((c) => `- \`${c.name}\` ${c.from} → ${c.to} (moved because ${c.reason})`), + '', + ); + if (report.companionBlockers?.length) + lines.push( + 'Blocked: a required move only a new major makes, which Uptide never makes:', + '', + ...report.companionBlockers.map((b) => `- ${b}`), '', ); if (report.companionStays?.length) diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 2e61074..3fcf8d7 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -348,6 +348,8 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise p.companionConflicts ?? []))]; const stays = [...new Set(packages.flatMap((p) => p.companionStays ?? []))].sort(); + // A required move only a new major makes is never made: the PR says so instead. + const blockers = [...new Set(packages.flatMap((p) => p.companionBlockers ?? []))].sort(); if (conflicts.length > 0) throw new UptideError( 'INCONSISTENT_UPGRADE', @@ -758,6 +760,7 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise { + // batch-ai moved zod with openai; guardrails moved @openai/agents and zod across their majors. + const own = [ + { + companions: [ + { + name: 'zod', + from: '3.24.2', + to: '3.25.0', + reason: 'openai 7.4.0 requires zod ^3.25 || ^4.0', + required: true as const, + }, + ], + }, + ] as unknown as CheckReport['packages']; + expect(companionOutcome(own, { zod: '4.4.3' })).toEqual({ companions: own[0]?.companions }); + const blocker = + '@openai/agents 0.1.11 depends on openai ^5.20.2; only a new major accepts openai 7.9.0 (@openai/agents 0.15.0, which needs zod ^4.0.0; installed 3.25.76)'; + const blocked = [{ companionBlockers: [blocker] }] as unknown as CheckReport['packages']; + expect(companionOutcome(blocked, { '@openai/agents': '0.17.2', zod: '4.5.4' })).toEqual({ + blockers: [blocker], + leftBehind: [ + `@openai/agents: the upgrade moved it to 0.17.2, check leaves it (blocked: ${blocker})`, + `zod: the upgrade moved it to 4.5.4, check leaves it (blocked: ${blocker})`, + ], + }); +}); diff --git a/packages/core/src/packs/tooling/pack-test.ts b/packages/core/src/packs/tooling/pack-test.ts index e0fe341..792e8a3 100644 --- a/packages/core/src/packs/tooling/pack-test.ts +++ b/packages/core/src/packs/tooling/pack-test.ts @@ -53,6 +53,8 @@ export interface RepoScore { stays?: string[]; /** Packages the real upgrade moved (`with`) that `check` would leave behind, or cannot move consistently. */ leftBehind?: string[]; + /** Required moves `check` will not make, each only a new major (`check/required.ts`). */ + blockers?: string[]; predicted: ScoredSite[]; expected: ScoredSite[]; /** Predicted sites no expected finding has, at the line level. */ @@ -218,22 +220,10 @@ async function scoreRepo( if (installed.length > 0 && !installed.includes(entry.from)) base.installedMismatch = `the lockfile has ${installed.join(', ')}, the ground truth says ${entry.from}`; const own = report.packages.filter((p) => p.name === pack.name); - const companions = [ - ...new Map(own.flatMap((p) => p.companions ?? []).map((c) => [c.name, c])).values(), - ].sort((a, b) => a.name.localeCompare(b.name)); - const stays = [...new Set(own.flatMap((p) => p.companionStays ?? []))].sort(); - const leftBehind = [ - ...new Set(own.flatMap((p) => p.companionConflicts ?? [])), - ...Object.entries(entry.with ?? {}) - .filter(([name]) => !companions.some((c) => c.name === name)) - .map(([name, version]) => `${name}: the upgrade moved it to ${version}, check leaves it`), - ]; const predicted = predictedSites(report, pack); return { ...base, - ...(companions.length ? { companions } : {}), - ...(stays.length ? { stays } : {}), - ...(leftBehind.length ? { leftBehind } : {}), + ...companionOutcome(own, entry.with), predicted, ...scoreSites(predicted, expected), }; @@ -242,6 +232,39 @@ async function scoreRepo( } } +/** + * What `check` moves with the package in one repository, against what its real upgrade moved + * (`with`): a package the upgrade moved that `check` does not is left behind, and says why + * when a blocker names it (a required move only a new major makes). + */ +export function companionOutcome( + own: CheckReport['packages'], + moved: Record = {}, +): Pick { + const companions = [ + ...new Map(own.flatMap((p) => p.companions ?? []).map((c) => [c.name, c])).values(), + ].sort((a, b) => a.name.localeCompare(b.name)); + const stays = [...new Set(own.flatMap((p) => p.companionStays ?? []))].sort(); + const blockers = [...new Set(own.flatMap((p) => p.companionBlockers ?? []))].sort(); + const names = (blocker: string, name: string) => + new RegExp(`(^|[ (])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} `).test(blocker); + const leftBehind = [ + ...new Set(own.flatMap((p) => p.companionConflicts ?? [])), + ...Object.entries(moved) + .filter(([name]) => !companions.some((c) => c.name === name)) + .map(([name, version]) => { + const blocker = blockers.find((b) => names(b, name)); + return `${name}: the upgrade moved it to ${version}, check leaves it${blocker ? ` (blocked: ${blocker})` : ''}`; + }), + ]; + return { + ...(companions.length ? { companions } : {}), + ...(stays.length ? { stays } : {}), + ...(blockers.length ? { blockers } : {}), + ...(leftBehind.length ? { leftBehind } : {}), + }; +} + export interface PackTestOptions extends EnsureOptions { /** The uptide checkout: packs live in `/packages/core/src/packs`. */ root: string;