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

Packs can name alternate leads: packages a repository declares instead of the pack's own (`react-router-dom` for `react-router`). `check` and `fix` asked for either name use the pack; where only the alternate is declared, the lead is analyzed at the version installed through it, pnpm stores included, and `fix` declares it beside the alternate at the target before upgrading both.
26 changes: 26 additions & 0 deletions docs/packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,29 @@ behind. Undeclared packages retain existing companion behavior.
For Next, the declared members are `eslint-config-next`, `@next/eslint-plugin-next`,
`@next/bundle-analyzer`, `@next/mdx`, and `@next/third-parties`; declare only installed
members of this release family, not independently versioned React packages.

### Alternate leads

Some packages are mostly installed through another one that re-exports them: most React
Router 6 applications declare `react-router-dom`, which depends on `react-router` at its own
exact version. `alternateLeads` names such packages, each with an official HTTPS `source`
(`pack test` validates them like companions):

```ts
releaseGroup: [{ name: 'react-router-dom', source }],
alternateLeads: [{ name: 'react-router-dom', source }],
```

- Asked for by an alternate's name, `check` and `fix` use the pack and report under its lead
(`uptide check react-router-dom` is `uptide check react-router`); a `--target` named after
the alternate is the lead's.
- In a workspace that declares the alternate and not the lead, the lead is analyzed at the
version installed through the alternate (hoisted, or beside the alternate in a pnpm store)
and reported as undeclared, `via` the alternate, with a note that `fix` declares it.
- `fix` then declares the lead next to the alternate, at the target, in the same manifest
section and range style (`^6.30.1` gives `^7.18.4`), before the upgrade; the alternate moves
as the release group says. A catalog, alias or workspace reference to the alternate is
refused with what to write by hand. Removing the alternate is left to a later upgrade that
requires it, once nothing imports it.
- Only packages asked for by name are re-attributed; a pack of the alternate's own name wins
over one that names it as an alternate.
59 changes: 59 additions & 0 deletions packages/cli/src/alternate-lead.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { run } from './cli.js';
import { fakeEngine, memoryIo, pnpmGitRepo } from './test-utils.js';

// A verified pack whose lead (`router`) a repository may declare only through `router-dom`.
vi.mock('@uptide/core', async (original) => {
const core = await original<typeof import('@uptide/core')>();
const source = 'https://example.com/router-dom/changelog';
const router = core.definePack({
meta: { package: 'router', from: '^1', to: '^2', sources: [], maintainer: 'test' },
rules: [],
instructions: '',
releaseGroup: [{ name: 'router-dom', source }],
alternateLeads: [{ name: 'router-dom', source }],
});
return { ...core, activePacks: () => [...core.activePacks(), router] };
});

/** Declares `router-dom` only, as most React Router 6 applications declare `react-router-dom`. */
const engine = () =>
fakeEngine({
installed: async () => new Map([['router-dom', '1.0.0']]),
declared: async () => new Map([['router-dom', '^1.0.0']]),
});
const repo = () =>
pnpmGitRepo({
'package.json': JSON.stringify({
name: 'shop',
packageManager: 'pnpm@10.17.1',
dependencies: { 'router-dom': '^1.0.0' },
}),
'node_modules/router-dom/package.json': '{"name":"router-dom","version":"1.0.0"}',
});

describe('a pack lead asked for, or declared, through its alternate lead', () => {
it.each(['router', 'router-dom'])('check %s checks the lead', async (asked) => {
const e = engine();
const io = memoryIo({ cwd: repo() });
expect(await run(['check', asked, '--target', `${asked}@2.0.0`], io, e)).toBe(0);
expect(io.stderr()).not.toContain('not a dependency');
expect(e.calls[0]).toMatchObject({ only: ['router'] });
});

it('fix router-dom fixes the lead, with a target named after the alternate', async () => {
const e = engine();
const io = memoryIo({ cwd: repo() });
expect(
await run(['fix', 'router-dom', '--target', 'router-dom@2.0.0', '--no-llm'], io, e),
).toBe(0);
expect(e.calls[0]).toMatchObject({ only: 'router', target: 'router@2.0.0' });
});

it('still says what is missing when neither is declared', async () => {
const e = fakeEngine();
const io = memoryIo({ cwd: repo() });
expect(await run(['check', 'router-dom'], io, e)).toBe(2);
expect(io.stderr()).toContain('router is not a dependency');
});
});
81 changes: 63 additions & 18 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import {
errorCode,
formatFix,
isFailure,
leadName,
leadNames,
loadUptideConfig,
PeerPreflightError,
PRICE_DATE,
packageSelected,
packLeading,
priceFor,
publicationFailureReason,
registeredPacks,
Expand Down Expand Up @@ -232,6 +235,24 @@ export async function run(
);
};

/** A lead nobody declares stands as the alternate lead its pack names, where one is declared. */
const withAlternates = async (
repo: Repo,
deps: Awaited<ReturnType<typeof locateDependencies>>,
): Promise<Awaited<ReturnType<typeof locateDependencies>>> => {
const alternates = (name: string) =>
(packLeading(name, activePacks())?.alternateLeads ?? []).map((a) => a.name);
const wanted = deps.filter((d) => d.workspaces.length === 0).flatMap((d) => alternates(d.name));
if (!wanted.length) return deps;
const found = await locateDependencies(repo, engine, wanted);
return deps.map(
(d) =>
(d.workspaces.length === 0 &&
found.find((a) => alternates(d.name).includes(a.name) && a.workspaces.length > 0)) ||
d,
);
};

/** Before any analysis: what was asked for exists and the repository is installed. */
const requireDependencies = (
repo: Repo,
Expand All @@ -247,10 +268,12 @@ export async function run(
only ?? (await declaredDependencies(repo, engine)),
);
if (only) {
// Asked for by name and absent is an error, not an empty answer.
const absent = deps.filter((d) => d.workspaces.length === 0).map((d) => d.name);
// Asked for by name and absent is an error, not an empty answer, unless a pack's
// alternate lead is declared instead (`react-router-dom` for `react-router`).
const present = await withAlternates(repo, deps);
const absent = present.filter((d) => d.workspaces.length === 0).map((d) => d.name);
if (absent.length > 0) throw notADependency(repo, absent);
requireInstalled(repo, deps);
requireInstalled(repo, present);
} else requireNodeModules(repo, deps);
return deps;
},
Expand Down Expand Up @@ -500,7 +523,11 @@ ${EXIT_CODES(
if (flags.open && !flags.html) throw new CliError('--open requires --html');
const started = io.now();
const quiet = !flags.verbose;
const only = [...new Set([...packages, ...(list(flags.only) ?? [])])];
const requested = [...new Set([...packages, ...(list(flags.only) ?? [])])];
// An alternate lead's name (`react-router-dom`) asks for its pack's lead.
const only = requested.includes('all')
? requested
: leadNames(requested, activePacks());
if ((!flags.group && only.length === 0) || only.includes('all'))
throw new CliError(
'check requires one or more package names. Start with uptide list.',
Expand All @@ -517,7 +544,7 @@ ${EXIT_CODES(
describeRepo,
);
const loaded = settings(cwd, flags.config);
namedOutside(loaded, only);
namedOutside(loaded, requested);
if (flags.group) {
if (!engine.list) throw new CliError('this build cannot discover groups');
const discoverGroup = engine.list;
Expand Down Expand Up @@ -552,7 +579,12 @@ ${EXIT_CODES(
);
only.push(...kept.filter((name) => !only.includes(name)));
}
const targets = parseTargets(flags.target, only);
// Named after what was asked for; an alternate lead's target is its lead's.
const targets = Object.fromEntries(
Object.entries(parseTargets(flags.target, [...new Set([...requested, ...only])])).map(
([name, version]) => [leadName(name, activePacks()), version],
),
);
const htmlReport = async (report: CheckReport): Promise<void> => {
if (flags.html) {
const path = writeHtml(
Expand Down Expand Up @@ -902,16 +934,26 @@ ${PRIVACY}`,
const loaded = settings(cwd, flags.config);
if (packageName && flags.only && packageName !== flags.only)
throw new CliError('choose one package for fix');
const only = packageName ?? flags.only;
if (!only)
const requested = packageName ?? flags.only;
if (!requested)
throw new CliError('fix requires a package name. Start with uptide list.', {
next: 'uptide list',
});
if (only.includes(','))
throw new CliError(`--only ${only}: fix upgrades one dependency at a time`, {
next: `uptide fix ${only.split(',')[0]}`,
if (requested.includes(','))
throw new CliError(`--only ${requested}: fix upgrades one dependency at a time`, {
next: `uptide fix ${requested.split(',')[0]}`,
});
namedOutside(loaded, [only]);
namedOutside(loaded, [requested]);
// An alternate lead's name (`react-router-dom`) fixes its pack's lead, and a target
// named after the alternate is the lead's.
const only = leadName(requested, activePacks());
const target =
only === requested
? flags.target
: flags.target?.replace(
new RegExp(`^${requested.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}@`),
`${only}@`,
);
// Flags first, then uptide.yml, then the defaults.
const maxCost =
flags.maxCost !== undefined ? Number(flags.maxCost) : (loaded?.config.maxCost ?? 1);
Expand All @@ -931,12 +973,12 @@ ${PRIVACY}`,
throw new CliError('--pin-current-api applies to stripe only', {
next: 'uptide fix stripe --pin-current-api',
});
if (flags.pinCurrentApi && flags.target)
if (flags.pinCurrentApi && target)
throw new CliError('--pin-current-api keeps the installed version; drop --target', {
next: 'uptide fix stripe --pin-current-api',
});
// `--only zod --target stripe@23.0.0` would upgrade zod to 23.0.0, or fail late.
if (flags.target) parseTargets([flags.target], [only]);
if (target) parseTargets([target], [only]);
if (flags.pr && !flags.yes)
io.err('--pr without --yes: the publication plan is printed, nothing is pushed.\n');
const repo = await progress.phase(
Expand All @@ -946,11 +988,14 @@ ${PRIVACY}`,
requireFixable(
found,
only,
flags.target ? parseTargets([flags.target], [only])[only] : undefined,
target ? parseTargets([target], [only])[only] : undefined,
);
const deps = await withAlternates(
found,
await locateDependencies(found, engine, [only]),
);
const deps = await locateDependencies(found, engine, [only]);
if (deps.every((d) => d.workspaces.length === 0))
throw notADependency(found, [only]);
throw notADependency(found, [requested]);
requireInstalled(found, deps);
return found;
},
Expand Down Expand Up @@ -1024,7 +1069,7 @@ ${PRIVACY}`,
{
cwd: repo.root,
only,
target: flags.target,
target: target,
includeDeprecated: flags.includeDeprecated,
...(peers.length ? { also: peers } : {}),
...(flags.allowPeer?.length ? { allowPeer: flags.allowPeer } : {}),
Expand Down
Loading
Loading