Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/types-stub-removal.md
Original file line number Diff line number Diff line change
@@ -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/<pkg>` to is deprecated on npm, and `<pkg>`'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.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<name> 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".
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/format-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/format-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,20 @@ 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 &&
deprecated.length === 0 &&
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')}`;
Expand Down Expand Up @@ -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}`)}`);
Expand Down
93 changes: 71 additions & 22 deletions packages/core/src/check/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [],
Expand All @@ -1780,18 +1819,26 @@ async function checkGroup(
for (const p of prepared) {
try {
// With DefinitelyTyped, the declarations diffed are @types/<name>'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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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 },
),
Expand Down Expand Up @@ -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(', ');
}
Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/check/companions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { satisfies } from '../fetch/range.js';
import type { Manifest } from '../list/evidence.js';
import { dependencyGroups } from '../list/groups.js';
import type { ListedDependency } from '../list/list.js';
import { type Removal, stubRemovals } from './removed.js';
import { requiredMoves } from './required.js';
import { typedPackageOf, typesReleaseFor } from './types-release.js';
import { compareVersions, parseVersion } from './version.js';
Expand Down Expand Up @@ -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<string, Pick<Manifest, 'dependencies' | 'peerDependencies'>>;
type Manifests = Record<
string,
Pick<Manifest, 'dependencies' | 'peerDependencies'> & { deprecated?: string }
>;

const EXACT = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
const scopeOf = (name: string): string | undefined =>
Expand Down Expand Up @@ -239,15 +248,31 @@ interface CompanionInput {
releaseGroup?: readonly string[];
/** Targets already planned before an explicitly requested peer upgrade. */
peerTargets?: Record<string, string>;
/**
* 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<boolean>;
}

/**
* 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<CompanionPlan> {
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;
Expand All @@ -259,7 +284,10 @@ export async function companionsOf(input: CompanionInput): Promise<CompanionPlan
installed: input.installed,
manifests: input.manifests,
planned: plan.companions,
reported: new Set([...plan.conflicts, ...plan.peerConflicts, ...(plan.stays ?? [])].map(first)),
reported: new Set([
...[...plan.conflicts, ...plan.peerConflicts, ...(plan.stays ?? [])].map(first),
...(plan.removals ?? []).map((r) => r.name),
]),
});
if (!required.moves.length && !required.blockers.length) return plan;
return {
Expand Down
Loading
Loading