diff --git a/.changeset/types-stub-removal.md b/.changeset/types-stub-removal.md new file mode 100644 index 0000000..de9fbde --- /dev/null +++ b/.changeset/types-stub-removal.md @@ -0,0 +1,5 @@ +--- +'uptide': minor +--- + +`check` and `fix` remove a deprecated `@types` stub instead of upgrading to it. When the release the upgrade would move `@types/` to is deprecated on npm, and ``'s target declares its own types (`types`, `typings` or a `types` export), the plan removes it: "Removed: @types/ioredis, ioredis 5 ships its own types", in `check` and in the PR body. `check` now diffs such a package against its own target types instead of failing on a stub with no declarations, and `fix` removes the entry in the same install, with the install scope guard allowing exactly that removal. diff --git a/README.md b/README.md index 0764281..063845e 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,11 @@ npx uptide fix zod # migrate on a new branch, verified; npx uptide pr --branch uptide/zod-4.6.5 # prints the plan; add --yes to push and open a draft PR ``` -`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. 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. +`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. What the target +requires moves within its major, else is a blocker; a deprecated `@types` stub is removed, not +upgraded. 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 9a2ff8b..16838e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,6 +124,18 @@ in `check`, the PR body and `pack test`; a plain dependency gets its own nested 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. +A `@types` package the group would move to a deprecated stub is removed instead +(`src/check/removed.ts`), before what the planned versions require is planned. Once a package +ships its own types, DefinitelyTyped publishes a last release that only depends on it, and npm +marks it deprecated (`@types/ioredis` 5.0.0, `@types/stripe` 8.0.417). Both must hold: npm's +`deprecated` field on the release the plan would reach, and the package's target manifest +declaring its own types (`types`, `typings` or a `types` export). Either alone keeps the +upgrade. The removal is a `companionRemovals` entry ("`@types/ioredis`, ioredis 5 ships its own +types"), never a companion, shown in `check` and in the PR body's "Removed:" section. `fix` +takes it out of every workspace that installs it (a peer declaration stays) in the same single +install, and the scope guard allows exactly that: an importer's edge to it may go, and so may +what only it reached; nothing about it may be added or changed. + ### One dependency, one decision A dependency at the same installed version and target in several workspaces is one entry @@ -211,7 +223,9 @@ noted as a warning of its own), the target is the @types release of the target's them for the runtime specifier (a baseline overlay when the baseline differs from what is installed), and findings are reported under the runtime package labeled "types via @types/ a → b". Identical releases skip the compile: the compiler has nothing to -judge. The runtime manifests still feed the module-format check. A curated list of +judge. When the plan removes the `@types` stub, the target side is the package's own +declarations at its target ("@types/ioredis 4.28.10 → ioredis 5.4.1's own types"), not the +stub, which declares nothing. The runtime manifests still feed the module-format check. A curated list of runtime-only changes (`src/check/runtime-changes.ts`: express 5 routing syntax, `app.del`, `req.param`, `res.send(status)`) is matched syntactically on the files that use the package and reported as `unverified`, labeled "runtime change, not visible in types". diff --git a/packages/cli/src/format-check.test.ts b/packages/cli/src/format-check.test.ts index 0b9583d..9951397 100644 --- a/packages/cli/src/format-check.test.ts +++ b/packages/cli/src/format-check.test.ts @@ -297,6 +297,50 @@ describe('formatCheck, the first screen', () => { ); }); + it('names a deprecated @types stub it removes, apart from what it upgrades', () => { + const out = formatCheck( + report([ + pkg({ + name: 'ioredis', + installed: '4.28.5', + latest: '5.4.1', + target: '5.4.1', + majorsBehind: 1, + status: 'breaking', + findings: [removed('src/index.ts', 3)], + companionRemovals: [ + { name: '@types/ioredis', from: '4.28.10', reason: 'ioredis 5 ships its own types' }, + ], + }), + ]), + { ...shown, fixable: ['ioredis'] }, + ); + expect(out).toContain( + '− removed: @types/ioredis, ioredis 5 ships its own types npm deprecates its new release as a stub; was 4.28.10', + ); + expect(out).not.toContain('upgrades with'); + }); + + it('names a removed @types stub on a package where nothing breaks', () => { + const out = formatCheck( + report([ + pkg({ + name: 'ioredis', + installed: '4.28.5', + latest: '5.4.1', + target: '5.4.1', + majorsBehind: 1, + status: 'safe', + companionRemovals: [ + { name: '@types/ioredis', from: '4.28.10', reason: 'ioredis 5 ships its own types' }, + ], + }), + ]), + shown, + ); + expect(out).toContain('− removed: @types/ioredis, ioredis 5 ships its own types'); + }); + 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 84eb996..21d0225 100644 --- a/packages/cli/src/format-check.ts +++ b/packages/cli/src/format-check.ts @@ -260,6 +260,12 @@ function sectionLines(row: Row, colors: Colors): string[] { const coverage = row.p.compile?.coverage ? [colors.dim(` ${coverageLine(row.p.compile.coverage)}${preexisting}`)] : []; + // A deprecated `@types` stub the new version makes redundant: removed, not upgraded. Said + // even when nothing breaks, since `fix` changes the manifest for it. + const removals = (row.p.companionRemovals ?? []).map( + (r) => + ` ${colors.cyan('−')} removed: ${r.name}, ${r.reason} ${colors.dim(`npm deprecates its new release as a stub; was ${r.from}`)}`, + ); if ( acting.length === 0 && possible.length === 0 && @@ -267,7 +273,7 @@ function sectionLines(row: Row, colors: Colors): string[] { importers.length === 0 && peers.length === 0 ) - return row.summary ? [heading, ...coverage] : []; + return row.summary || removals.length ? [heading, ...coverage, ...removals] : []; const scope = (g: PlanGroup): string => { if (g.fixes !== g.sites) return `${plural(g.fixes, 'fix', 'fixes')}, ${plural(g.sites, 'error')}`; @@ -340,6 +346,7 @@ function sectionLines(row: Row, colors: Colors): string[] { lines.push( ` ${colors.red('✗')} blocked: ${blocker} ${colors.dim('a new major, never made by Uptide')}`, ); + lines.push(...removals); 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/core/src/check/check.ts b/packages/core/src/check/check.ts index d97111f..a59152c 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -58,6 +58,7 @@ import { import { foldOptionCascades } from './option-cascades.js'; import { mapWithLimit, mapWithSerialRetry } from './pool.js'; import { isBehind, rankCandidates } from './rank.js'; +import { ownTypesFrom } from './removed.js'; import { foldSharedRoots, groupRootCauses } from './root-cause.js'; import { runtimeChangeFindings } from './runtime-changes.js'; import { type StandIns, standInLeads } from './stand-ins.js'; @@ -713,6 +714,10 @@ export function mergeAcrossWorkspaces( 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 removals = [ + ...new Map(list.flatMap((p) => p.companionRemovals ?? []).map((r) => [r.name, r])).values(), + ].sort((a, b) => a.name.localeCompare(b.name)); + if (removals.length > 0) combined.companionRemovals = removals; const peerConflicts = [...new Set(list.flatMap((p) => p.peerConflicts ?? []))].sort(); if (peerConflicts.length > 0) combined.peerConflicts = peerConflicts; const members = [ @@ -1248,8 +1253,10 @@ async function companionPlans( releaseGroup: packsOf(opts) .find((p) => p.name === lead) ?.releaseGroup?.map((c) => c.name), + ...(fetcher.metadata ? { ownTypes: ownTypesFrom(fetcher.metadata) } : {}), }); if ( + plan.removals?.length || plan.companions.length > 0 || plan.conflicts.length > 0 || plan.peerConflicts.length > 0 || @@ -1295,6 +1302,12 @@ function memoizingFetcher(inner: PackageFetcher): RunFetcher { once(versions, name, () => inner.versions?.(name) ?? Promise.resolve([])), } : {}), + ...(inner.metadata + ? { + metadata: (name: string, version: string) => + inner.metadata?.(name, version) ?? Promise.resolve({}), + } + : {}), ...(inner.manifests ? { manifests: (name: string) => @@ -1380,7 +1393,17 @@ interface Prepared { /** The installed version ships no declarations: no surface, no diff; loads and manifests still count. */ untyped?: true; /** Declarations come from DefinitelyTyped: `installedDir` is the @types package, these are its versions. `baseline` when it differs from what is installed. */ - types?: { name: string; installedVersion: string; target: string; baseline?: string }; + types?: { + name: string; + installedVersion: string; + target: string; + baseline?: string; + /** + * The plan removes the @types stub (`removed.ts`): the target's declarations are the + * package's own, at `target`, the runtime's version. + */ + own?: true; + }; /** The runtime package's directory when `installedDir` is the @types package (for the manifest). */ runtimeDir?: PackageDir; target: string; @@ -1544,15 +1567,30 @@ async function prepare( // The @types release that types the target (its major.minor, else its major; never below // what is installed: @types/passport 1.x types passport 0.x), else the latest release. A // companion plan that moves the types package names the release outright. - const typesTarget = - ctx.opts.targets?.[typesName] ?? - typesReleaseFor(published, target, typesInstalled) ?? - typesInstalled; - if (major !== undefined && parseVersion(typesTarget)?.major !== major && published.length > 0) + // A stub the plan removes types nothing: the target is diffed as the package ships it. + const own = Object.values(ctx.opts.companions ?? {}).some((plan) => + plan.removals?.some((r) => r.name === typesName), + ); + const typesTarget = own + ? target + : (ctx.opts.targets?.[typesName] ?? + typesReleaseFor(published, target, typesInstalled) ?? + typesInstalled); + if ( + !own && + major !== undefined && + parseVersion(typesTarget)?.major !== major && + published.length > 0 + ) notes.push( `${typesName} has no ${major}.x release above ${typesInstalled}; diffed against ${typesTarget}`, ); - types = { name: typesName, installedVersion: typesInstalled, target: typesTarget }; + types = { + name: typesName, + installedVersion: typesInstalled, + target: typesTarget, + ...(own ? { own: true as const } : {}), + }; runtimeDir = installedDir; installedDir = { ...typesDir, version: typesInstalled }; // The baseline follows the runtime: express 4 is compared from @types/express 4.x even when @@ -1765,6 +1803,7 @@ async function checkGroup( ...(plan?.conflicts.length ? { companionConflicts: plan.conflicts } : {}), ...(plan?.stays?.length ? { companionStays: plan.stays } : {}), ...(plan?.blockers?.length ? { companionBlockers: plan.blockers } : {}), + ...(plan?.removals?.length ? { companionRemovals: plan.removals } : {}), ...(plan?.peerConflicts.length ? { peerConflicts: plan.peerConflicts } : {}), majorsBehind: Math.max(...prepared.map((p) => majorsBehind(p.installedVersion, p.target))), findings: [], @@ -1780,18 +1819,26 @@ async function checkGroup( for (const p of prepared) { try { // With DefinitelyTyped, the declarations diffed are @types/'s; the runtime package is fetched for its manifest. - const pkg = p.types - ? await progress(ctx.opts.onProgress, { phase: 'fetch', package: p.name, workspace }, () => - ctx.fetcher.fetch(p.types?.name ?? p.name, p.types?.target ?? p.target), - ) - : await progress(ctx.opts.onProgress, { phase: 'fetch', package: p.name, workspace }, () => - ctx.fetcher.fetch(p.name, p.target), - ); - const runtime = p.types - ? await progress(ctx.opts.onProgress, { phase: 'fetch', package: p.name, workspace }, () => - ctx.fetcher.fetch(p.name, p.target), - ) - : undefined; + const pkg = + p.types && !p.types.own + ? await progress( + ctx.opts.onProgress, + { phase: 'fetch', package: p.name, workspace }, + () => ctx.fetcher.fetch(p.types?.name ?? p.name, p.types?.target ?? p.target), + ) + : await progress( + ctx.opts.onProgress, + { phase: 'fetch', package: p.name, workspace }, + () => ctx.fetcher.fetch(p.name, p.target), + ); + const runtime = + p.types && !p.types.own + ? await progress( + ctx.opts.onProgress, + { phase: 'fetch', package: p.name, workspace }, + () => ctx.fetcher.fetch(p.name, p.target), + ) + : undefined; fetched.push({ p, pkg: p.installedDir.types ? { ...pkg, types: p.installedDir.types } : pkg, @@ -1884,7 +1931,9 @@ async function checkGroup( // Declarations identical on both sides (the same @types release) leave the compiler nothing to judge. const sameDeclarations = prepared.every( (p) => - p.types !== undefined && p.types.target === (p.types.baseline ?? p.types.installedVersion), + p.types !== undefined && + !p.types.own && + p.types.target === (p.types.baseline ?? p.types.installedVersion), ); if (sameDeclarations) notes.push('type declarations unchanged; compile check skipped'); if (ctx.opts.compile !== false && compileMany && !sameDeclarations) { @@ -1911,7 +1960,7 @@ async function checkGroup( repo, [ ...fetched.map(({ p, pkg }) => - p.types + p.types && !p.types.own ? { name: p.types.name, dir: pkg.dir, specifier: p.name } : { name: p.name, dir: pkg.dir }, ), @@ -2298,7 +2347,7 @@ async function checkGroup( report.typesVia = viaTypes .map( (p) => - `${p.types?.name} ${p.types?.baseline ?? p.types?.installedVersion} → ${p.types?.target}`, + `${p.types?.name} ${p.types?.baseline ?? p.types?.installedVersion} → ${p.types?.own ? `${p.name} ${p.types.target}'s own types` : p.types?.target}`, ) .join(', '); } diff --git a/packages/core/src/check/companions.ts b/packages/core/src/check/companions.ts index d962a5b..f1b752e 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 { type Removal, stubRemovals } from './removed.js'; import { requiredMoves } from './required.js'; import { typedPackageOf, typesReleaseFor } from './types-release.js'; import { compareVersions, parseVersion } from './version.js'; @@ -48,11 +49,19 @@ export interface CompanionPlan { * needs zod ^4.0.0; installed 3.25.76, and only a new major satisfies it (zod 4.0.0)`. */ blockers?: string[]; + /** + * `@types` stubs removed instead of upgraded (`removed.ts`): `@types/ioredis`, `ioredis 5 + * ships its own types`. Never among `companions`. + */ + removals?: Removal[]; /** The group as `list` names it (`@ai-sdk family, shared @ai-sdk/provider-utils`). */ reason?: string; } -type Manifests = Record>; +type Manifests = Record< + string, + Pick & { deprecated?: string } +>; const EXACT = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/; const scopeOf = (name: string): string | undefined => @@ -239,15 +248,31 @@ interface CompanionInput { releaseGroup?: readonly string[]; /** Targets already planned before an explicitly requested peer upgrade. */ peerTargets?: Record; + /** + * Whether a version declares its own types, from its full manifest. Without it no `@types` + * stub is removed (`removed.ts`): it is upgraded as before. + */ + ownTypes?: (name: string, version: string) => 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`). + * release group, less the `@types` stubs it removes instead (`removed.ts`), then what the + * planned versions require of the rest (`required.ts`). */ export async function companionsOf(input: CompanionInput): Promise { - const plan = await releaseGroupOf(input); + let plan = await releaseGroupOf(input); + if (input.ownTypes) { + const { companions, removals } = await stubRemovals({ + lead: input.name, + target: input.target, + companions: plan.companions, + manifests: input.manifests, + ownTypes: input.ownTypes, + }); + if (removals.length) plan = { ...plan, companions, removals }; + } 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; @@ -259,7 +284,10 @@ export async function companionsOf(input: CompanionInput): Promise r.name), + ]), }); if (!required.moves.length && !required.blockers.length) return plan; return { diff --git a/packages/core/src/check/removed.test.ts b/packages/core/src/check/removed.test.ts new file mode 100644 index 0000000..6d62f0e --- /dev/null +++ b/packages/core/src/check/removed.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { companionsOf, type InstalledDependency } from './companions.js'; + +type Versions = Record< + string, + { + dependencies?: Record; + peerDependencies?: Record; + deprecated?: string; + } +>; + +// As npm publishes them: the last @types release of each is a stub that only depends on the +// package, deprecated once (`@types/stripe` 8.0.0 to 8.0.416 are stubs npm never flagged). +const stub = (name: string) => + `This is a stub types definition. ${name} provides its own type definitions, so you do not need this installed.`; +const registry: Record = { + ioredis: { '4.28.5': {}, '5.4.1': {} }, + '@types/ioredis': { + '4.28.10': { dependencies: { '@types/node': '*' } }, + '5.0.0': { dependencies: { ioredis: '*' }, deprecated: stub('ioredis') }, + }, + stripe: { '7.15.0': {}, '8.0.0': {}, '18.0.0': {} }, + '@types/stripe': { + '7.13.25': { dependencies: { '@types/node': '*' } }, + '8.0.0': { dependencies: { stripe: '*' } }, + '8.0.416': { dependencies: { stripe: '*' } }, + '8.0.417': { dependencies: { stripe: '*' }, deprecated: stub('stripe') }, + }, +}; +// What each version's full manifest declares: ioredis 5 `types`, stripe 8 `types`, stripe 18 +// `types` and a `types` export. Neither ioredis 4 nor stripe 7 ships any. +const typed = new Set(['ioredis@5.4.1', 'stripe@8.0.0', 'stripe@18.0.0']); +const ownTypes = async (name: string, version: string) => typed.has(`${name}@${version}`); + +const installed = (name: string, version: string): InstalledDependency => ({ + name, + version, + manifest: { name, version, ...registry[name]?.[version] }, + workspaces: ['.'], +}); +const plan = ( + lead: string, + from: string, + target: string, + types: [string, string], + extra: Partial[0]> = {}, +) => + companionsOf({ + name: lead, + target, + installed: [installed(lead, from), installed(...types)], + manifests: async (name) => registry[name] ?? {}, + ownTypes, + ...extra, + }); + +describe('a deprecated @types stub is removed instead of upgraded', () => { + it('removes @types/ioredis: ioredis 5 ships its own types and @types/ioredis 5.0.0 is a stub', async () => { + const result = await plan('ioredis', '4.28.5', '5.4.1', ['@types/ioredis', '4.28.10']); + expect(result.companions).toEqual([]); + expect(result.removals).toEqual([ + { name: '@types/ioredis', from: '4.28.10', reason: 'ioredis 5 ships its own types' }, + ]); + }); + + it('removes @types/stripe: the release the plan would reach is the deprecated stub', async () => { + const result = await plan('stripe', '7.15.0', '18.0.0', ['@types/stripe', '7.13.25']); + expect(result.companions).toEqual([]); + expect(result.removals).toEqual([ + { name: '@types/stripe', from: '7.13.25', reason: 'stripe 18 ships its own types' }, + ]); + }); + + it('upgrades it as before when the release it reaches is not deprecated', async () => { + // stripe 8.0.0 ships types, but @types/stripe 8.0.416 carries no deprecation: npm's word + // decides, not the stub's look. + const result = await plan('stripe', '7.15.0', '8.0.0', ['@types/stripe', '7.13.25'], { + manifests: async (name) => + name === '@types/stripe' + ? { '7.13.25': {}, '8.0.416': { dependencies: { stripe: '*' } } } + : (registry[name] ?? {}), + }); + expect(result.removals).toBeUndefined(); + expect(result.companions).toEqual([ + expect.objectContaining({ name: '@types/stripe', from: '7.13.25', to: '8.0.416' }), + ]); + }); + + it('upgrades it as before when the target does not declare its own types', async () => { + const result = await plan('ioredis', '4.28.5', '5.4.1', ['@types/ioredis', '4.28.10'], { + ownTypes: async () => false, + }); + expect(result.removals).toBeUndefined(); + expect(result.companions).toEqual([ + expect.objectContaining({ name: '@types/ioredis', from: '4.28.10', to: '5.0.0' }), + ]); + }); + + it('upgrades it as before when nothing can say whether the target ships types', async () => { + const result = await plan('ioredis', '4.28.5', '5.4.1', ['@types/ioredis', '4.28.10'], { + ownTypes: undefined, + }); + expect(result.removals).toBeUndefined(); + expect(result.companions.map((c) => c.name)).toEqual(['@types/ioredis']); + }); +}); diff --git a/packages/core/src/check/removed.ts b/packages/core/src/check/removed.ts new file mode 100644 index 0000000..82e55bf --- /dev/null +++ b/packages/core/src/check/removed.ts @@ -0,0 +1,65 @@ +import type { PackageFetcher } from '../domain/io.js'; +import type { Companion } from './companions.js'; +import { typedPackageOf } from './types-release.js'; +import { parseVersion } from './version.js'; + +/** A `@types` stub taken out instead of upgraded: `@types/ioredis`, `ioredis 5 ships its own types`. */ +export interface Removal { + name: string; + from: string; + reason: string; +} + +/** + * The `@types/` companions to remove rather than upgrade. Once a package ships its own + * types, DefinitelyTyped publishes a last `@types` release that only depends on it, and npm + * deprecates it as a stub (`@types/ioredis` 5.0.0, `@types/stripe` 8.0.417). A companion is + * removed when the release the plan moves it to is deprecated **and** the version its package + * moves to declares its own types (`types`, `typings` or a `types` export). Either alone keeps + * the upgrade: a deprecation can mean something else, and a package's own types without a stub + * leave the `@types` release the one that types it. + */ +export async function stubRemovals(input: { + lead: string; + target: string; + companions: readonly Companion[]; + manifests: (name: string) => Promise>; + ownTypes: (name: string, version: string) => Promise; +}): Promise<{ companions: Companion[]; removals: Removal[] }> { + const planned = new Map([ + [input.lead, input.target], + ...input.companions.map((c) => [c.name, c.to] as const), + ]); + const companions: Companion[] = []; + const removals: Removal[] = []; + for (const c of input.companions) { + const host = typedPackageOf(c.name); + const version = host === undefined ? undefined : planned.get(host); + const stub = + host !== undefined && + version !== undefined && + (await input.manifests(c.name))[c.to]?.deprecated !== undefined && + (await input.ownTypes(host, version)); + if (!stub) { + companions.push(c); + continue; + } + // `ioredis 5`, as the line a caret range reaches; a 0.x version is named in full. + const major = parseVersion(version)?.major; + removals.push({ + name: c.name, + from: c.from, + reason: `${host} ${major ? major : version} ships its own types`, + }); + } + return { companions, removals }; +} + +/** + * Whether a version declares its own types, from its full manifest; a registry that cannot + * answer says no, so the `@types` package is upgraded as before rather than removed. + */ +export const ownTypesFrom = + (metadata: NonNullable) => + async (name: string, version: string): Promise => + (await metadata(name, version).catch(() => undefined))?.ownTypes === true; diff --git a/packages/core/src/domain/io.ts b/packages/core/src/domain/io.ts index e512126..57fda79 100644 --- a/packages/core/src/domain/io.ts +++ b/packages/core/src/domain/io.ts @@ -18,15 +18,20 @@ export interface PackageFetcher { dependencies?: Record; peerDependencies?: Record; bin?: string | Record; + /** Whether the version declares its own types (`types`, `typings` or a `types` export). */ + ownTypes?: boolean; }>; versions?(name: string): Promise; /** Every published version's dependencies and peers. Optional: without it no companion moves with a package. */ - manifests?( - name: string, - ): Promise< + manifests?(name: string): Promise< Record< string, - { dependencies?: Record; peerDependencies?: Record } + { + dependencies?: Record; + peerDependencies?: Record; + /** npm's deprecation message, when the version has one. */ + deprecated?: string; + } > >; /** Why the last `resolve(name, 'latest')` answered an older version: the release-age policy. */ diff --git a/packages/core/src/domain/report.ts b/packages/core/src/domain/report.ts index 62ea301..399acda 100644 --- a/packages/core/src/domain/report.ts +++ b/packages/core/src/domain/report.ts @@ -161,6 +161,11 @@ export interface PackageReport { * `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[]; + /** + * `@types` stubs removed instead of upgraded (`check/removed.ts`): npm deprecates the release + * the upgrade would reach, and the package ships its own types. `ioredis 5 ships its own types`. + */ + companionRemovals?: { name: string; from: string; reason: 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/fetch/npm-fetcher.test.ts b/packages/core/src/fetch/npm-fetcher.test.ts index 1f61c59..fb9797d 100644 --- a/packages/core/src/fetch/npm-fetcher.test.ts +++ b/packages/core/src/fetch/npm-fetcher.test.ts @@ -316,6 +316,55 @@ it('reads and caches peer/bin metadata without tarballs, including caches from o expect(existsSync(tarballCachePath(cacheDir, 'demo', '1.0.0'))).toBe(false); }); +it.each([ + ['a types field (ioredis 5)', { types: './built/index.d.ts' }, true], + ['a typings field', { typings: 'index.d.ts' }, true], + [ + 'a types export only', + { exports: { '.': { types: './types/index.d.ts', default: './index.js' } } }, + true, + ], + ['no types (ioredis 4)', { main: 'built/index.js' }, false], +])('says whether a version ships its own types: %s', async (_, fields, ownTypes) => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ name: 'demo', version: '1.0.0', ...fields, dist: { tarball: 'x' } }), + ), + ); + const fetcher = createNpmFetcher({ + cacheDir: mkdtempSync(join(tmpdir(), 'uptide-types-')), + config, + fetch: fetchFn as typeof fetch, + }); + expect((await fetcher.metadata?.('demo', '1.0.0'))?.ownTypes).toBe(ownTypes || undefined); +}); + +it("keeps npm's deprecation message in every version's manifest (a @types stub)", async () => { + const deprecated = 'This is a stub types definition. ioredis provides its own type definitions'; + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ + name: '@types/ioredis', + versions: { + '4.28.10': { dependencies: { '@types/node': '*' } }, + '5.0.0': { dependencies: { ioredis: '*' }, deprecated }, + }, + }), + ), + ); + const fetcher = createNpmFetcher({ + cacheDir: mkdtempSync(join(tmpdir(), 'uptide-deprecated-')), + config, + fetch: fetchFn as typeof fetch, + }); + expect(await fetcher.manifests?.('@types/ioredis')).toEqual({ + '4.28.10': { dependencies: { '@types/node': '*' } }, + '5.0.0': { dependencies: { ioredis: '*' }, deprecated }, + }); +}); + it.each([ { registry: 'https://registry.npmjs.org', scoped: {}, tokens: {}, publicRegistry: true }, { diff --git a/packages/core/src/fetch/npm-fetcher.ts b/packages/core/src/fetch/npm-fetcher.ts index 3bd422f..67d7283 100644 --- a/packages/core/src/fetch/npm-fetcher.ts +++ b/packages/core/src/fetch/npm-fetcher.ts @@ -200,7 +200,8 @@ export function createNpmFetcher(opts: NpmFetcherOptions = {}): PackageFetcher { return { metadata: (name, version) => remembered( - [...packagePathSegments(name), `metadata-${version}`], + // `manifest-`, not the `metadata-` entries kept before `ownTypes` was read. + [...packagePathSegments(name), `manifest-${version}`], EXACT_VERSION.test(version), async () => { const manifest = await resolveVersion(name, version, config, fetchFn); @@ -208,6 +209,7 @@ export function createNpmFetcher(opts: NpmFetcherOptions = {}): PackageFetcher { ...(manifest.dependencies ? { dependencies: manifest.dependencies } : {}), peerDependencies: manifest.peerDependencies ?? {}, bin: manifest.bin, + ...(manifest.ownTypes ? { ownTypes: true } : {}), }; }, ), diff --git a/packages/core/src/fetch/registry.ts b/packages/core/src/fetch/registry.ts index a6d4b58..341dbb5 100644 --- a/packages/core/src/fetch/registry.ts +++ b/packages/core/src/fetch/registry.ts @@ -18,6 +18,8 @@ export interface ResolvedVersion { dependencies?: Record; peerDependencies?: Record; bin?: string | Record; + /** Whether the version declares its own types: `types`, `typings` or a `types` export. */ + ownTypes?: boolean; /** SRI string (`sha512-…`) when the registry provides one. */ integrity?: string; /** Hex sha1, older registries only. */ @@ -28,6 +30,10 @@ interface Manifest { dependencies?: Record; peerDependencies?: Record; bin?: string | Record; + types?: unknown; + typings?: unknown; + exports?: unknown; + deprecated?: string; version?: string; dist?: { tarball?: string; integrity?: string; shasum?: string }; } @@ -165,11 +171,25 @@ function toResolved(name: string, manifest: Manifest | undefined): ResolvedVersi peerDependencies: manifest.peerDependencies ?? {}, }; if (manifest.bin) resolved.bin = manifest.bin; + if (declaresTypes(manifest)) resolved.ownTypes = true; if (manifest.dist.integrity) resolved.integrity = manifest.dist.integrity; if (manifest.dist.shasum) resolved.shasum = manifest.dist.shasum; return resolved; } +/** `types`/`typings`, or a `types` condition anywhere in `exports` (stripe 18's `"./types/index.d.ts"`). */ +function declaresTypes(manifest: Manifest): boolean { + const condition = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + Object.entries(value).some(([key, v]) => key === 'types' || condition(v)); + return ( + typeof manifest.types === 'string' || + typeof manifest.typings === 'string' || + condition(manifest.exports) + ); +} + /** Accepts an exact version or a dist-tag. Ranges are deliberately unsupported: a diff needs two exact points. */ export function resolveFromPackument( name: string, @@ -244,6 +264,8 @@ export async function listVersions( export interface VersionDependencies { dependencies?: Record; peerDependencies?: Record; + /** npm's deprecation message: `@types/ioredis` 5.0.0 is "a stub types definition". */ + deprecated?: string; } /** @@ -268,6 +290,7 @@ export async function listManifests( out[version] = { ...(manifest.dependencies ? { dependencies: manifest.dependencies } : {}), ...(manifest.peerDependencies ? { peerDependencies: manifest.peerDependencies } : {}), + ...(manifest.deprecated ? { deprecated: manifest.deprecated } : {}), }; } return out; diff --git a/packages/core/src/fix/generic.test.ts b/packages/core/src/fix/generic.test.ts index a3e4cc5..434fbd0 100644 --- a/packages/core/src/fix/generic.test.ts +++ b/packages/core/src/fix/generic.test.ts @@ -544,6 +544,35 @@ describe('fix with companions', () => { expect(body).toContain('a required move is blocked'); }, 20000); + it('removes a deprecated @types stub in the same install, and names it apart', async () => { + const { root, report, services, fixer, installs } = withBrush(); + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + manifest.devDependencies = { '@types/paint': '^1.0.0' }; + writeFileSync(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + git(root, 'commit', '-am', 'types'); + const lead = report.packages[0] as CheckReport['packages'][number]; + delete lead.companions; + lead.companionRemovals = [ + { name: '@types/paint', from: '1.0.0', reason: 'paint 2 ships its own types' }, + ]; + const result = await fix({ cwd: root, only: 'paint', fixer: fixer(0.01) }, services); + // One install: the lead moves, the stub goes, and the scope guard is told so. + expect(installs).toHaveLength(1); + expect(installs[0]).toMatchObject({ name: 'paint', remove: ['@types/paint'] }); + expect(installs[0]?.also).toBeUndefined(); + expect(installs[0]?.workspaces).toEqual(['.']); + const after = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + expect(after.devDependencies).toEqual({}); + expect(after.dependencies.brush).toBe('1.0.0'); + expect(git(root, 'log', '--format=%s', '-n', '1', 'HEAD~1')).toBe( + 'chore: upgrade paint to 2.0.0, remove @types/paint', + ); + expect(result.companionRemovals).toEqual(lead.companionRemovals); + const body = prBody(result); + expect(body).toContain('Removed:\n\n- `@types/paint`, paint 2 ships its own types'); + expect(body).not.toContain('Upgraded with paint'); + }, 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 4490151..33cd2f4 100644 --- a/packages/core/src/fix/managers/lock-guard.ts +++ b/packages/core/src/fix/managers/lock-guard.ts @@ -200,16 +200,27 @@ export function describeTransitive(change: TransitiveChange): string { ? `removed ${change.name} ${change.from}` : `added ${change.name} ${change.to}`; } -/** Union authorized subtrees, then protect every outside dependent's actual resolution. */ +/** + * Union authorized subtrees, then protect every outside dependent's actual resolution. + * `removed` names the `@types` stubs the plan takes out (`check/removed.ts`): an importer's + * edge to one may disappear, and so may an entry only it reached; nothing about it may be + * added or changed. Build both graphs with them among the targets, so the importers' + * declarations of them are not compared. + */ export function assertLockScope( before: LockGraph, after: LockGraph, target: Targets, extraRoots: readonly string[] = [], + removed: readonly string[] = [], ): LockDiff { const names = targetNames(target); const originalScope = lockScope(before, after, target); const allowed = lockScope(before, after, target, extraRoots); + const dropped = closure( + before, + [...before.records].filter(([, r]) => removed.includes(r.name)).map(([k]) => k), + ); const diff: LockDiff = { added: [], removed: [], changed: [], allowed: [...allowed].sort() }; const housekeeping: LockHousekeeping = { deduped: [], metadata: [] }; const unexpected = new Set(); @@ -239,7 +250,8 @@ export function assertLockScope( declared = false, ): boolean => [...new Set([...Object.keys(left), ...Object.keys(right)])].every((name) => { - if (!Object.hasOwn(left, name) || !Object.hasOwn(right, name)) return false; + if (!Object.hasOwn(left, name) || !Object.hasOwn(right, name)) + return declared && removed.includes(name) && !Object.hasOwn(right, name); const a = left[name], b = right[name]; if (a === undefined || b === undefined) return a === b; @@ -287,6 +299,8 @@ export function assertLockScope( if (changed) diff[a === undefined ? 'added' : b === undefined ? 'removed' : 'changed'].push(key); if (allowed.has(key)) continue; + // What only a removed stub reached goes with it; an outside dependent's edge to it still compares. + if (b === undefined && dropped.has(key)) continue; const other = a ? counterpart(key, before, after) : (counterpart(key, after, before, true) ?? relocated.get(key)); diff --git a/packages/core/src/fix/managers/removed-scope.test.ts b/packages/core/src/fix/managers/removed-scope.test.ts new file mode 100644 index 0000000..5ce997d --- /dev/null +++ b/packages/core/src/fix/managers/removed-scope.test.ts @@ -0,0 +1,190 @@ +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'; + +// A repository on ioredis 4 with @types/ioredis at the root, and @types/node reached only +// through it. The plan moves ioredis 4.28.5 → 5.4.1 and removes @types/ioredis, whose last +// release is a deprecated stub: ioredis 5 ships its own types (check/removed.ts). +interface State { + ioredis: string; + /** Absent once removed. */ + types?: string; + other?: string; +} +const nodeTypes = '20.0.0'; +const npm = ({ ioredis, types, other = '1.0.0' }: State) => + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { + dependencies: { ioredis: '^4.28.5', other: '^1.0.0' }, + devDependencies: types ? { '@types/ioredis': '^4.28.10' } : {}, + }, + 'node_modules/ioredis': { version: ioredis, integrity: `sha512-ioredis-${ioredis}` }, + 'node_modules/other': { version: other, integrity: `sha512-other-${other}` }, + ...(types + ? { + 'node_modules/@types/ioredis': { + version: types, + integrity: `sha512-types-${types}`, + dev: true, + dependencies: types.startsWith('4') ? { '@types/node': '*' } : { ioredis: '*' }, + }, + ...(types.startsWith('4') + ? { + 'node_modules/@types/node': { + version: nodeTypes, + integrity: `sha512-node-${nodeTypes}`, + dev: true, + }, + } + : {}), + } + : {}), + }, + }); +const pnpm = ({ ioredis, types, other = '1.0.0' }: State) => `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ioredis: + specifier: ^4.28.5 + version: ${ioredis} + other: + specifier: ^1.0.0 + version: ${other} +${ + types + ? ` devDependencies: + '@types/ioredis': + specifier: ^4.28.10 + version: ${types} +` + : '' +} +packages: +${ + types + ? ` + '@types/ioredis@${types}': + resolution: {integrity: sha512-types-${types}} +${ + types.startsWith('4') + ? ` + '@types/node@${nodeTypes}': + resolution: {integrity: sha512-node-${nodeTypes}} +` + : '' +}` + : '' +} + ioredis@${ioredis}: + resolution: {integrity: sha512-ioredis-${ioredis}} + + other@${other}: + resolution: {integrity: sha512-other-${other}} + +snapshots: +${ + types + ? ` + '@types/ioredis@${types}': + dependencies: +${types.startsWith('4') ? ` '@types/node': ${nodeTypes}` : ` ioredis: ${ioredis}`} +${types.startsWith('4') ? `\n '@types/node@${nodeTypes}': {}\n` : ''}` + : '' +} + ioredis@${ioredis}: {} + + other@${other}: {} +`; +const yarn = ({ + ioredis, + types, + other = '1.0.0', +}: State) => `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +${ + types + ? ` +"@types/ioredis@^4.28.10": + version "${types}" + resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-${types}.tgz" + integrity sha512-types-${types} + dependencies: + ${types.startsWith('4') ? '"@types/node" "*"' : 'ioredis "*"'} +${ + types.startsWith('4') + ? ` +"@types/node@*": + version "${nodeTypes}" + resolved "https://registry.yarnpkg.com/@types/node/-/node-${nodeTypes}.tgz" + integrity sha512-node-${nodeTypes} +` + : '' +}` + : '' +} +ioredis@^4.28.5: + version "${ioredis}" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-${ioredis}.tgz" + integrity sha512-ioredis-${ioredis} + +other@^1.0.0: + version "${other}" + resolved "https://registry.yarnpkg.com/other/-/other-${other}.tgz" + integrity sha512-other-${other} +`; + +const managers: [string, (text: string, target: Targets) => LockGraph, (s: State) => string][] = [ + ['npm', npmGraph, npm], + ['pnpm', pnpmGraph, pnpm], + ['yarn', yarnGraph, yarn], +]; +const before: State = { ioredis: '4.28.5', types: '4.28.10' }; +const removed = ['@types/ioredis']; + +describe.each(managers)( + '%s: the install scope guard and a removed @types stub', + (_, graph, lock) => { + // As upgradeInstall builds them: the removed stub is among the graphs' targets, not the moves. + const guard = (after: State, remove: readonly string[]) => + assertLockScope( + graph(lock(before), ['ioredis', ...remove]), + graph(lock(after), ['ioredis', ...remove]), + 'ioredis', + [], + remove, + ); + + it('allows the planned removal, and what only the stub reached going with it', () => { + const diff = guard({ ioredis: '5.4.1' }, removed); + expect(diff.removed.some((k) => k.includes('@types/ioredis'))).toBe(true); + expect(diff.removed.some((k) => k.includes('@types/node'))).toBe(true); + }); + + it('refuses the removal when the plan did not make it', () => { + expect(() => guard({ ioredis: '5.4.1' }, [])).toThrow(/outside ioredis's dependency subtree/); + }); + + it('refuses the stub moving to its deprecated release instead of going', () => { + expect(() => guard({ ioredis: '5.4.1', types: '5.0.0' }, removed)).toThrow( + /outside ioredis's dependency subtree/, + ); + }); + + it('still refuses anything else that moves next to the removal', () => { + expect(() => guard({ ioredis: '5.4.1', other: '1.1.0' }, removed)).toThrow( + /outside ioredis's dependency subtree/, + ); + }); + }, +); diff --git a/packages/core/src/fix/managers/upgrade.ts b/packages/core/src/fix/managers/upgrade.ts index 8f5bd25..b384e3a 100644 --- a/packages/core/src/fix/managers/upgrade.ts +++ b/packages/core/src/fix/managers/upgrade.ts @@ -19,6 +19,11 @@ export interface Upgrade { * and the lockfile may change inside any of their subtrees, never outside them. */ also?: { name: string; version: string }[]; + /** + * `@types` stubs the plan removes (`check/removed.ts`), already taken out of the manifests: + * the lockfile may lose them and what only they reached, nothing more. + */ + remove?: string[]; /** Packages whose peer edges the user explicitly overrides, without changing their ranges. */ allowedPeers?: string[]; } @@ -57,7 +62,8 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise m.name); - const before = lockGraph(pm, beforeText, names); + const removed = upgrade.remove ?? []; + const before = lockGraph(pm, beforeText, [...names, ...removed]); const originals = new Map( upgrade.workspaces.map((w) => { const file = join(w, 'package.json'); @@ -134,7 +140,7 @@ export async function upgradeInstall(root: string, upgrade: Upgrade): Promise p.records), + removed, ); } catch (error) { if (pm.kind === 'npm' && error instanceof UptideError && error.code === 'LOCKFILE_OUT_OF_SCOPE') diff --git a/packages/core/src/fix/peer-preflight.ts b/packages/core/src/fix/peer-preflight.ts index 276b4c1..295e590 100644 --- a/packages/core/src/fix/peer-preflight.ts +++ b/packages/core/src/fix/peer-preflight.ts @@ -6,6 +6,7 @@ import { compatiblePeerVersion, peerConflictOf, } from '../check/companions.js'; +import { ownTypesFrom } from '../check/removed.js'; import { parseVersion } from '../check/version.js'; import type { PackageFetcher } from '../domain/io.js'; import { UptideError } from '../errors.js'; @@ -133,6 +134,9 @@ export async function peerPreflight( const fetcher = createNpmFetcher({ config: loadRegistryConfig({ cwd: options.cwd }) }); const fetch = services?.manifests ?? fetcher.manifests; if (!fetch) throw new Error('Registry manifests are required to plan peers'); + // Injected manifests come with their own metadata or none: never the network beside them. + const metadata = services?.manifests ? services.metadata : fetcher.metadata; + const ownTypes = metadata ? { ownTypes: ownTypesFrom(metadata) } : {}; const cache = new Map>(); const manifests: Manifests = (name) => { let pending = cache.get(name); @@ -159,6 +163,7 @@ export async function peerPreflight( manifests, lockstep: pack?.companions?.map((c) => c.name) ?? [], releaseGroup: pack?.releaseGroup?.map((c) => c.name), + ...ownTypes, }); if (plan.conflicts.length) throw new Error(plan.conflicts.join('; ')); const base = new Map([ @@ -244,6 +249,7 @@ export async function peerPreflight( manifests, lockstep: activePack(peer.name)?.companions?.map((c) => c.name) ?? [], releaseGroup: activePack(peer.name)?.releaseGroup?.map((c) => c.name), + ...ownTypes, }); if (linked.conflicts.length) throw new Error(linked.conflicts.join('; ')); for (const c of linked.companions) if (!moves.has(c.name)) moves.set(c.name, c); diff --git a/packages/core/src/fix/range-preflight.ts b/packages/core/src/fix/range-preflight.ts index 85f1f67..97eedde 100644 --- a/packages/core/src/fix/range-preflight.ts +++ b/packages/core/src/fix/range-preflight.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { workspacePackagesOf } from '../adapters/typescript/repo.js'; import { hoistedPackageDir } from '../adapters/typescript/usages.js'; import { companionsOf, type InstalledDependency } from '../check/companions.js'; +import { ownTypesFrom } from '../check/removed.js'; import { createNpmFetcher } from '../fetch/npm-fetcher.js'; import { loadRegistryConfig } from '../fetch/npmrc.js'; import { installedManifest, type Manifest } from '../list/evidence.js'; @@ -40,6 +41,8 @@ export async function rangePreflight( validateVersionRanges(root, [{ name: options.only, to: target }], read); if (names.size === 1) return target; const manifests = services?.manifests ?? fetcher.manifests; + // Injected manifests come with their own metadata or none: never the network beside them. + const metadata = services?.manifests ? services.metadata : fetcher.metadata; if (!manifests) throw new Error('Registry manifests are required to plan companions'); const plan = await companionsOf({ name: options.only, @@ -48,6 +51,7 @@ export async function rangePreflight( manifests, lockstep: pack?.companions?.map((c) => c.name) ?? [], releaseGroup: pack?.releaseGroup?.map((c) => c.name), + ...(metadata ? { ownTypes: ownTypesFrom(metadata) } : {}), }); if (plan.conflicts.length) throw new Error(plan.conflicts.join('; ')); validateVersionRanges(root, plan.companions, read); diff --git a/packages/core/src/fix/report.ts b/packages/core/src/fix/report.ts index 3dc0975..7c2f56d 100644 --- a/packages/core/src/fix/report.ts +++ b/packages/core/src/fix/report.ts @@ -763,6 +763,13 @@ export function renderMigration( ...report.companionBlockers.map((b) => `- ${b}`), '', ); + if (report.companionRemovals?.length) + lines.push( + 'Removed:', + '', + ...report.companionRemovals.map((r) => `- \`${r.name}\`, ${r.reason}`), + '', + ); if (report.companionStays?.length) lines.push( 'Left where they are (not released with it):', diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 3fcf8d7..de6a7a6 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -78,6 +78,7 @@ import { declareLead, install, packageManager, + removeDeclarations, validateVersionRanges, } from './versions.js'; @@ -157,6 +158,8 @@ export interface FixServices { resolve?(name: string, tag: string): Promise; /** Registry manifests for companion range preflight, before cloning. */ manifests?: PackageFetcher['manifests']; + /** Full manifests, for whether a version ships its own types (`check/removed.ts`). */ + metadata?: PackageFetcher['metadata']; /** `--agent sdk` sessions; the Claude Agent SDK by default. */ agentRunner?: TaskRunner; /** Whether the optional SDK peer can be imported; decides the default executor. */ @@ -350,6 +353,10 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise 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(); + // A `@types` stub the new version makes redundant goes, in the same install (check/removed.ts). + const removals = [ + ...new Map(packages.flatMap((p) => p.companionRemovals ?? []).map((r) => [r.name, r])).values(), + ].sort((a, b) => a.name.localeCompare(b.name)); if (conflicts.length > 0) throw new UptideError( 'INCONSISTENT_UPGRADE', @@ -438,6 +445,7 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise bumpVersions(root, c.name, c.to)), + ...removals.map((r) => removeDeclarations(root, r.name)), ]; const bump = { files: [ @@ -472,14 +480,17 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise ({ name: c.name, version: c.to })) } : {}), + ...(removals.length ? { remove: removals.map((r) => r.name) } : {}), }), ); assertInstalledVersions(root, [{ name: pack.name, to: target }, ...companions]); const moved = companions.map((c) => `${c.name} ${c.to}`).join(', '); - commit(root, `chore: upgrade ${pack.name} to ${target}${moved ? ` with ${moved}` : ''}`, [ - ...bump.files, - join(root, packageManager(root).lockfile), - ]); + const dropped = removals.map((r) => r.name).join(', '); + commit( + root, + `chore: upgrade ${pack.name} to ${target}${moved ? ` with ${moved}` : ''}${dropped ? `, remove ${dropped}` : ''}`, + [...bump.files, join(root, packageManager(root).lockfile)], + ); // The install replaced node_modules, and what was generated into it with them. if (generated.length) await verify('generate', () => (services.generate ?? generateClients)(root, workspaces)); @@ -761,6 +772,7 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise