From fc9c468275af353df57e70e31f2b77ee0abac541 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:33:39 +0000 Subject: [PATCH] feat(packs): alternate leads, for a pack's package installed through another Most React Router 6 applications declare react-router-dom, which installs react-router at its own exact version; `check react-router` answered "not a dependency" there, and a pack for react-router never ran. A pack can now name such packages as `alternateLeads`, each with its official source. - `check` and `fix` asked for an alternate's name use the pack and report under its lead; a `--target` named after the alternate is the lead's. - In a workspace that declares only the alternate, `check` analyzes the lead at the version installed through it (hoisted, or beside the alternate in a pnpm store), as undeclared `via` the alternate, with a note that `fix` declares it. The result matches the same repository declaring the lead. - `fix` plans the lead's release group from that copy and declares the lead beside the alternate, in the same section and range style, before the upgrade; both move in one install. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EV6fvvLupC2Hdbwcg7iovY Signed-off-by: Claude --- .changeset/alternate-pack-leads.md | 5 + docs/packs.md | 26 ++ packages/cli/src/alternate-lead.test.ts | 59 ++++ packages/cli/src/cli.ts | 81 +++-- .../core/src/check/alternate-lead.test.ts | 234 +++++++++++++++ packages/core/src/check/check.ts | 67 ++++- packages/core/src/check/stand-ins.ts | 64 ++++ packages/core/src/fix/alternate-lead.test.ts | 284 ++++++++++++++++++ packages/core/src/fix/peer-preflight.ts | 6 +- packages/core/src/fix/run.ts | 16 +- packages/core/src/fix/stand-in-lead.ts | 39 +++ packages/core/src/fix/versions.ts | 46 +++ packages/core/src/index.ts | 1 + packages/core/src/packs/contract.ts | 2 + packages/core/src/packs/leads.test.ts | 30 ++ packages/core/src/packs/leads.ts | 25 ++ .../core/src/packs/tooling/pack-test.test.ts | 7 + packages/core/src/packs/tooling/pack-test.ts | 3 + packages/core/src/packs/types.ts | 8 + 19 files changed, 975 insertions(+), 28 deletions(-) create mode 100644 .changeset/alternate-pack-leads.md create mode 100644 packages/cli/src/alternate-lead.test.ts create mode 100644 packages/core/src/check/alternate-lead.test.ts create mode 100644 packages/core/src/check/stand-ins.ts create mode 100644 packages/core/src/fix/alternate-lead.test.ts create mode 100644 packages/core/src/fix/stand-in-lead.ts create mode 100644 packages/core/src/packs/leads.test.ts create mode 100644 packages/core/src/packs/leads.ts diff --git a/.changeset/alternate-pack-leads.md b/.changeset/alternate-pack-leads.md new file mode 100644 index 0000000..3d4d1a3 --- /dev/null +++ b/.changeset/alternate-pack-leads.md @@ -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. diff --git a/docs/packs.md b/docs/packs.md index aa9029a..5fd9799 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -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. diff --git a/packages/cli/src/alternate-lead.test.ts b/packages/cli/src/alternate-lead.test.ts new file mode 100644 index 0000000..e48b40c --- /dev/null +++ b/packages/cli/src/alternate-lead.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5a80bfb..02a6c3e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,10 +6,13 @@ import { errorCode, formatFix, isFailure, + leadName, + leadNames, loadUptideConfig, PeerPreflightError, PRICE_DATE, packageSelected, + packLeading, priceFor, publicationFailureReason, registeredPacks, @@ -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>, + ): Promise>> => { + 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, @@ -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; }, @@ -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.', @@ -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; @@ -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 => { if (flags.html) { const path = writeHtml( @@ -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); @@ -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( @@ -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; }, @@ -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 } : {}), diff --git a/packages/core/src/check/alternate-lead.test.ts b/packages/core/src/check/alternate-lead.test.ts new file mode 100644 index 0000000..196847a --- /dev/null +++ b/packages/core/src/check/alternate-lead.test.ts @@ -0,0 +1,234 @@ +import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createTypescriptAdapter } from '../adapters/typescript/index.js'; +import type { PackageFetcher, SurfaceCache } from '../domain/io.js'; +import type { ApiSurface } from '../domain/surface.js'; +import { definePack } from '../packs/contract.js'; +import { check } from './check.js'; +import { standInLeads } from './stand-ins.js'; + +process.env.UPTIDE_CACHE_DIR = mkdtempSync(join(tmpdir(), 'uptide-test-cache-')); + +const adapter = createTypescriptAdapter({ now: () => new Date('2026-09-27T00:00:00.000Z') }); +const source = 'https://example.com/router-dom/changelog'; + +function memoryCache(): SurfaceCache { + const store = new Map(); + return { + async get(k) { + return store.get(`${k.package}@${k.version}`); + }, + async set(k, s) { + store.set(`${k.package}@${k.version}`, s); + }, + }; +} + +/** + * `router` as `react-router` is, and `router-dom` as `react-router-dom`: a release of the same + * version that depends on it exactly and re-exports it. 2.0.0 makes `navigate` take options. + */ +function writePackage(dir: string, name: string, version: string): void { + mkdirSync(dir, { recursive: true }); + const manifest: Record = { + name, + version, + main: 'index.js', + types: 'index.d.ts', + }; + if (name === 'router-dom') { + manifest.dependencies = { router: version }; + // Named re-exports, as react-router-dom 6 lists what it takes from react-router. + writeFileSync(join(dir, 'index.d.ts'), "export { navigate } from 'router';\n"); + writeFileSync(join(dir, 'index.js'), "module.exports = require('router');\n"); + } else { + writeFileSync( + join(dir, 'index.d.ts'), + version === '1.0.0' + ? 'export declare function navigate(to: string): void;\n' + : 'export declare function navigate(to: string, options: { replace: boolean }): void;\n', + ); + writeFileSync(join(dir, 'index.js'), 'exports.navigate = () => {};\n'); + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)); +} + +/** A repository that declares only `router-dom`, installed hoisted (npm) or in a store (pnpm). */ +function consumer(layout: 'hoisted' | 'store'): string { + const repo = mkdtempSync(join(tmpdir(), 'uptide-alternate-lead-')); + const dependencies = { 'router-dom': '1.0.0' }; + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ name: 'consumer', version: '0.0.0', dependencies }), + ); + if (layout === 'hoisted') { + writePackage(join(repo, 'node_modules', 'router-dom'), 'router-dom', '1.0.0'); + writePackage(join(repo, 'node_modules', 'router'), 'router', '1.0.0'); + } else { + // pnpm: the workspace sees router-dom only; router sits beside it in the store. + const store = join(repo, 'node_modules', '.pnpm', 'router-dom@1.0.0', 'node_modules'); + writePackage(join(store, 'router-dom'), 'router-dom', '1.0.0'); + writePackage(join(store, 'router'), 'router', '1.0.0'); + symlinkSync(join(store, 'router-dom'), join(repo, 'node_modules', 'router-dom')); + } + writeFileSync( + join(repo, 'package-lock.json'), + JSON.stringify({ + name: 'consumer', + lockfileVersion: 3, + packages: { + '': { name: 'consumer', dependencies }, + 'node_modules/router-dom': { version: '1.0.0', dependencies: { router: '1.0.0' } }, + 'node_modules/router': { version: '1.0.0' }, + }, + }), + ); + writeFileSync( + join(repo, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { strict: true, noEmit: true }, include: ['src'] }), + ); + mkdirSync(join(repo, 'src')); + writeFileSync( + join(repo, 'src/index.ts'), + "import { navigate } from 'router-dom';\nnavigate('/');\n", + ); + return repo; +} + +const pack = definePack({ + meta: { package: 'router', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + rules: [], + instructions: '', + inventory: ['package.json'], + releaseGroup: [{ name: 'router-dom', source }], + alternateLeads: [{ name: 'router-dom', source }], +}); + +const fetcher: PackageFetcher = { + async resolve(_name, requested) { + return requested === 'latest' ? '2.0.0' : requested; + }, + async versions() { + return ['1.0.0', '2.0.0']; + }, + async manifests(name) { + return Object.fromEntries( + ['1.0.0', '2.0.0'].map((v) => [ + v, + name === 'router-dom' ? { dependencies: { router: v } } : {}, + ]), + ); + }, + async fetch(name, version) { + const dir = mkdtempSync(join(tmpdir(), 'uptide-target-')); + writePackage(dir, name, version); + return { name, version, dir }; + }, +}; + +/** What `check` reports for `router`, with the code importing from `from`. */ +async function checkRouter(cwd: string, asked: string, from: string, packs = [pack]) { + writeFileSync(join(cwd, 'src/index.ts'), `import { navigate } from '${from}';\nnavigate('/');\n`); + const result = await check({ + cwd, + only: [asked], + packs, + adapter, + fetcher, + cache: memoryCache(), + runtime: false, + }); + return result.packages; +} + +/** The same repository with the lead declared as well: what a stand-in must match. */ +function declaring(cwd: string): string { + for (const file of ['package.json', 'package-lock.json']) { + const json = JSON.parse(readFileSync(join(cwd, file), 'utf8')); + const deps = file === 'package.json' ? json.dependencies : json.packages[''].dependencies; + deps.router = '1.0.0'; + writeFileSync(join(cwd, file), JSON.stringify(json)); + } + return cwd; +} + +const summary = (p: Awaited>[number] | undefined) => ({ + status: p?.status, + installed: p?.installed, + target: p?.target, + members: p?.members, + findings: p?.findings.map((f) => [f.severity, f.change.path, f.usage.file, f.usage.line]), +}); + +describe('a lead declared only through its alternate lead', () => { + it.each([ + ['router', 'hoisted'], + ['router-dom', 'hoisted'], + ['router', 'store'], + ['router-dom', 'store'], + ] as const)( + 'check %s (%s install) analyzes the pack lead, the alternate moving with it', + async (asked, layout) => { + const packages = await checkRouter(consumer(layout), asked, 'router-dom'); + expect(packages.map((p) => p.name)).toEqual(['router']); + const router = packages[0]; + expect(router).toMatchObject({ installed: '1.0.0', target: '2.0.0' }); + expect(router?.undeclared).toEqual({ via: 'router-dom' }); + expect(router?.members).toEqual( + expect.arrayContaining([ + { name: 'router', installed: '1.0.0', target: '2.0.0' }, + { name: 'router-dom', installed: '1.0.0', target: '2.0.0' }, + ]), + ); + expect(router?.notes).toContain( + 'router is installed through router-dom, which this workspace declares instead: fix declares router 2.0.0 beside it', + ); + // Analyzed, not skipped: in a store the lead is found beside the alternate's own copy. + expect(router?.status).toBe('safe'); + if (layout === 'hoisted') { + // Exactly what the same repository gets when it declares the lead itself. + const declared = await checkRouter(declaring(consumer(layout)), 'router', 'router-dom'); + expect(summary(router)).toEqual(summary(declared[0])); + } + }, + ); + + it('reports the breaking call when the code imports the lead, asked for by the alternate', async () => { + const packages = await checkRouter(consumer('hoisted'), 'router-dom', 'router'); + expect(packages.map((p) => [p.name, p.status])).toEqual([['router', 'breaking']]); + expect(summary(packages[0]).findings).toEqual([['breaking', 'navigate', 'src/index.ts', 2]]); + }); + + it('leaves the alternate alone without a pack that names it', async () => { + const packages = await checkRouter(consumer('hoisted'), 'router-dom', 'router-dom', []); + expect(packages.map((p) => p.name)).toEqual(['router-dom']); + }); +}); + +describe('standInLeads', () => { + it('finds the lead hoisted and in the store, only when asked and undeclared', () => { + for (const layout of ['hoisted', 'store'] as const) { + const repo = consumer(layout); + const declared = { '.': { 'router-dom': '1.0.0' } }; + expect(standInLeads(repo, ['.'], declared, [pack], ['router'])['.']?.router).toMatchObject({ + via: 'router-dom', + version: '1.0.0', + }); + expect(standInLeads(repo, ['.'], declared, [pack], ['other'])).toEqual({}); + expect( + standInLeads( + repo, + ['.'], + { '.': { router: '1.0.0', 'router-dom': '1.0.0' } }, + [pack], + ['router'], + ), + ).toEqual({}); + expect( + standInLeads(repo, ['.'], { '.': { 'router-dom': 'workspace:*' } }, [pack], ['router']), + ).toEqual({}); + } + }); +}); diff --git a/packages/core/src/check/check.ts b/packages/core/src/check/check.ts index 6c2510b..b05e710 100644 --- a/packages/core/src/check/check.ts +++ b/packages/core/src/check/check.ts @@ -35,6 +35,7 @@ import { scanImports } from '../list/scan.js'; import { packFixability } from '../packs/fixability.js'; import { activePacks } from '../packs/index.js'; import { packInventory } from '../packs/inventory.js'; +import { leadName, leadNames } from '../packs/leads.js'; import type { MigrationPack } from '../packs/types.js'; import { diffRuntime, probeRuntime } from '../runtime/runtime.js'; import { resetSharedState } from '../shared-state.js'; @@ -58,6 +59,7 @@ import { mapWithLimit, mapWithSerialRetry } from './pool.js'; import { isBehind, rankCandidates } from './rank.js'; import { foldSharedRoots, groupRootCauses } from './root-cause.js'; import { runtimeChangeFindings } from './runtime-changes.js'; +import { type StandIns, standInLeads } from './stand-ins.js'; import { confirmBreaking, evidenceOf, tierOf } from './tier.js'; import { typedPackageOf, typesPackageOf, typesReleaseFor } from './types-release.js'; import { unattributedFindings } from './unattributed.js'; @@ -127,6 +129,11 @@ export interface CheckOptions { * versions that agree with its target). Analyzed as one group led by it, as `fix` upgrades. */ companions?: Record; + /** + * Set by `check`: a lead asked for by name that a workspace reaches only through an + * alternate lead it declares (`react-router` through `react-router-dom`), with its copy. + */ + standIns?: StandIns; /** * The packs to use instead of the verified ones (`uptide pack test` passes a candidate). * Packs are code, which no worker receives: with this set, workspaces are checked in this @@ -180,6 +187,28 @@ export async function check(opts: CheckOptions): Promise { .filter(([, spec]) => spec.startsWith('catalog:')) .map(([name]) => name); } + // Asked for by an alternate lead's name, the pack's lead is what is checked; a workspace that + // declares only the alternate has the lead through it, at the version installed there. + if (opts.only) { + const packs = packsOf(opts); + const only = leadNames(opts.only, packs); + const targets = opts.targets && { + ...Object.fromEntries(Object.entries(opts.targets).map(([n, v]) => [leadName(n, packs), v])), + ...Object.fromEntries(Object.entries(opts.targets).filter(([n]) => only.includes(n))), + }; + const standIns = standInLeads(opts.cwd, workspaces, installedByWorkspace, packs, only); + for (const [workspace, leads] of Object.entries(standIns)) + installedByWorkspace[workspace] = { + ...installedByWorkspace[workspace], + ...Object.fromEntries(Object.entries(leads).map(([lead, s]) => [lead, s.version])), + }; + opts = { + ...opts, + only, + ...(targets ? { targets } : {}), + ...(Object.keys(standIns).length ? { standIns } : {}), + }; + } // What moves with each package asked for by name, analyzed with it at the versions that // agree with its target: the upgrade `fix` makes, not one no install ever produces. const plans = await companionPlans(opts, memo, adapter, workspaces, installedByWorkspace); @@ -925,6 +954,8 @@ async function checkWorkspace(ctx: Ctx, job: WorkspaceJob): Promise [name, i])); const entries = [...installed] .sort( @@ -954,7 +985,15 @@ async function checkWorkspace(ctx: Ctx, job: WorkspaceJob): Promise ({ name, installed: version, - dependsOn: dependsOnOf(installedPackageDirOf(adapter, repo, name, version)?.dir), + dependsOn: dependsOnOf( + installedPackageDirOf( + adapter, + repo, + name, + version, + opts.standIns?.[workspace]?.[name]?.dir, + )?.dir, + ), })), ); // A package asked for by name leads its companions' group, and absorbs any release group @@ -1061,6 +1100,11 @@ async function checkWorkspace(ctx: Ctx, job: WorkspaceJob): Promise x.name.localeCompare(y.name))); @@ -1155,10 +1199,11 @@ async function companionPlans( installedPackageDirCheap?: (repo: RepoDir, pkg: string) => string | undefined; } ).installedPackageDirCheap; - const dir = cheap - ? cheap({ dir: resolve(opts.cwd, workspace) }, name) - : installedPackageDirOf(adapter, { dir: resolve(opts.cwd, workspace) }, name, version) - ?.dir; + const dir = + (cheap + ? cheap({ dir: resolve(opts.cwd, workspace) }, name) + : installedPackageDirOf(adapter, { dir: resolve(opts.cwd, workspace) }, name, version) + ?.dir) ?? opts.standIns?.[workspace]?.[name]?.dir; let manifest = {}; try { if (dir) manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); @@ -1446,7 +1491,13 @@ async function prepare( return base('safe', target, latest); } - let installedDir = installedPackageDirOf(ctx.adapter, repo, name, installedVersion); + let installedDir = installedPackageDirOf( + ctx.adapter, + repo, + name, + installedVersion, + ctx.opts.standIns?.[workspace]?.[name]?.dir, + ); const nodeTypes = installedPackageDirOf(ctx.adapter, repo, '@types/node', '')?.dir; if (installedDir && nodeTypes) installedDir.types = [nodeTypes]; // Typed through DefinitelyTyped: the surface to diff is @types/'s, at the version matching the target's major. @@ -2338,10 +2389,12 @@ function installedPackageDirOf( repo: RepoDir, name: string, version: string, + /** The copy a declared alternate lead installs, when the workspace cannot resolve `name`. */ + standIn?: string, ): PackageDir | undefined { const locate = ( adapter as { installedPackageDir?: (repo: RepoDir, pkg: string) => string | undefined } ).installedPackageDir; - const dir = locate?.(repo, name); + const dir = locate?.(repo, name) ?? standIn; return dir ? { name, version, dir } : undefined; } diff --git a/packages/core/src/check/stand-ins.ts b/packages/core/src/check/stand-ins.ts new file mode 100644 index 0000000..22dd086 --- /dev/null +++ b/packages/core/src/check/stand-ins.ts @@ -0,0 +1,64 @@ +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { hoistedPackageDir } from '../adapters/typescript/usages.js'; +import type { MigrationPack } from '../packs/types.js'; +import { readManifest } from './module-format.js'; + +/** A pack's lead that a workspace reaches only through an alternate lead it declares. */ +export interface StandIn { + /** The declared alternate (`react-router-dom`). */ + via: string; + /** The lead's installed package root, wherever the installer put it. */ + dir: string; + version: string; +} + +/** By workspace, then by lead. */ +export type StandIns = Record>; + +const linked = (spec: string): boolean => /^(link|workspace|file):/.test(spec); + +/** + * Where `lead` is installed for a workspace that declares `via`: hoisted next to it (npm, Yarn), + * or beside the alternate's own copy (pnpm keeps a dependency in the store directory of the + * package that depends on it, out of the workspace's reach). + */ +function leadDir(workspaceDir: string, lead: string, via: string): string | undefined { + const reachable = hoistedPackageDir(workspaceDir, lead); + if (reachable) return realpathSync(reachable); + const alternate = hoistedPackageDir(workspaceDir, via); + if (!alternate) return undefined; + const beside = hoistedPackageDir(realpathSync(alternate), lead); + return beside ? realpathSync(beside) : undefined; +} + +/** + * For each lead asked for by name that a workspace does not declare, the alternate lead it + * declares instead and the copy of the lead installed through it. Only what was asked for: + * a workspace's other dependencies are never re-attributed. + */ +export function standInLeads( + cwd: string, + workspaces: readonly string[], + installedByWorkspace: Record>, + packs: readonly MigrationPack[], + only: readonly string[], +): StandIns { + const found: StandIns = {}; + for (const pack of packs) { + if (!only.includes(pack.name) || !pack.alternateLeads?.length) continue; + for (const workspace of workspaces) { + const declared = installedByWorkspace[workspace] ?? {}; + if (declared[pack.name] !== undefined) continue; + const via = pack.alternateLeads + .map((a) => a.name) + .find((name) => declared[name] !== undefined && !linked(declared[name] as string)); + if (!via) continue; + const dir = leadDir(resolve(cwd, workspace), pack.name, via); + const version = dir ? readManifest(dir)?.version : undefined; + if (!dir || !version) continue; + found[workspace] = { ...found[workspace], [pack.name]: { via, dir, version } }; + } + } + return found; +} diff --git a/packages/core/src/fix/alternate-lead.test.ts b/packages/core/src/fix/alternate-lead.test.ts new file mode 100644 index 0000000..aa884c2 --- /dev/null +++ b/packages/core/src/fix/alternate-lead.test.ts @@ -0,0 +1,284 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import type { CheckReport, Finding } from '../domain/report.js'; +import { definePack } from '../packs/contract.js'; +import type { Upgrade } from './managers/upgrade.js'; +import { git } from './process.js'; +import type { FixServices } from './run.js'; +import { fix } from './run.js'; +import { diagnostics, testWorkspaces } from './verify.js'; +import { declareLead } from './versions.js'; + +const scratch = mkdtempSync(join(tmpdir(), 'uptide-alternate-lead-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); +const source = 'https://example.com/router-dom/changelog'; +const CLEAN_TOOL = { uptideVersion: '0.0.0', uptideCommit: 'a'.repeat(40), uptideDirty: false }; + +/** `router` 2.0.0 makes `navigate` take options; `router-dom` re-exports it, by name. */ +const pack = definePack({ + meta: { package: 'router', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + defaultTarget: '2.0.0', + releaseGroup: [{ name: 'router-dom', source }], + alternateLeads: [{ name: 'router-dom', source }], + rules: [ + { + id: 'navigate-options', + summary: 'navigate takes options', + severity: 'breaking', + kinds: ['signature'], + symbols: /navigate$/, + guide: 'pass { replace: false }', + rewrite: (text) => ({ + text: text.replace("navigate('/')", "navigate('/', { replace: false })"), + applied: true, + reason: 'options are required', + }), + }, + ], + instructions: '', +}); + +function writeRouter(root: string, version: string): void { + for (const name of ['router', 'router-dom']) { + const dir = join(root, 'node_modules', name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ + name, + version, + types: 'index.d.ts', + ...(name === 'router-dom' ? { dependencies: { router: version } } : {}), + }), + ); + writeFileSync( + join(dir, 'index.d.ts'), + name === 'router-dom' + ? "export { navigate } from 'router';\n" + : version === '1.0.0' + ? 'export declare function navigate(to: string): void;\n' + : 'export declare function navigate(to: string, options: { replace: boolean }): void;\n', + ); + } +} + +/** A committed repository that declares only `router-dom`, between two other dependencies. */ +function alternateFixture() { + const root = mkdtempSync(join(scratch, 'repo-')); + writeFileSync( + join(root, 'package.json'), + `${JSON.stringify( + { + name: 'consumer', + type: 'module', + dependencies: { alpha: '1.0.0', 'router-dom': '^1.0.0', zeta: '1.0.0' }, + }, + null, + 2, + )}\n`, + ); + writeRouter(root, '1.0.0'); + for (const name of ['alpha', 'zeta']) { + mkdirSync(join(root, 'node_modules', name), { recursive: true }); + writeFileSync( + join(root, 'node_modules', name, 'package.json'), + JSON.stringify({ name, version: '1.0.0' }), + ); + } + writeFileSync( + join(root, 'pnpm-lock.yaml'), + "lockfileVersion: '9.0'\nimporters:\n .:\n dependencies:\n router-dom:\n specifier: ^1.0.0\n version: 1.0.0\n", + ); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + skipLibCheck: true, + module: 'NodeNext', + moduleResolution: 'NodeNext', + }, + include: ['index.ts'], + }), + ); + writeFileSync(join(root, 'index.ts'), "import { navigate } from 'router-dom';\nnavigate('/');\n"); + writeFileSync(join(root, '.gitignore'), 'node_modules\n'); + git(root, 'init'); + git(root, 'config', 'user.email', 'test@example.test'); + git(root, 'config', 'user.name', 'Test'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'baseline'); + const finding: Finding = { + change: { + package: 'router', + from: '1.0.0', + to: '2.0.0', + path: 'navigate', + kind: 'signature', + severity: 'breaking', + confidence: 1, + source: 'types', + }, + usage: { + file: 'index.ts', + line: 2, + column: 1, + endLine: 2, + endColumn: 9, + symbolPath: 'navigate', + access: 'call', + snippet: "navigate('/');", + via: 'reexport', + compileCode: 2554, + }, + severity: 'breaking', + confidence: 1, + fixability: 'mechanical', + reason: 'navigate takes options', + }; + // What `check router-dom` reports there: the lead, through the alternate, moving it along. + const report: CheckReport = { + repo: root, + workspaces: ['.'], + summary: { + packagesNeedingAttention: 1, + breaking: 1, + deprecated: 0, + unverified: 0, + unaffected: 0, + notImported: 0, + partiallyAnalyzed: 0, + autoFixable: 1, + skippedForTime: 0, + failed: 0, + }, + packages: [ + { + name: 'router', + workspace: '.', + installed: '1.0.0', + latest: '2.0.0', + target: '2.0.0', + majorsBehind: 1, + findings: [finding], + callSitesChecked: 1, + status: 'breaking', + unanalyzed: [], + notes: [], + undeclared: { via: 'router-dom' }, + companions: [ + { name: 'router-dom', from: '1.0.0', to: '2.0.0', reason: 'declared release group' }, + ], + timing: { fetchMs: 0, diffMs: 0, usagesMs: 0, compileMs: 0 }, + }, + ], + }; + const installs: Upgrade[] = []; + const checked: string[][] = []; + const services: FixServices = { + check: async (options) => { + checked.push(options.only ?? []); + return report; + }, + diagnostics, + tests: testWorkspaces, + resolve: async () => '2.0.0', + // The registry for the peer plan: router-dom is released at each router version. + manifests: async (name) => + Object.fromEntries( + ['1.0.0', '2.0.0'].map((v) => [ + v, + name === 'router-dom' ? { dependencies: { router: v } } : {}, + ]), + ), + install: async (dir, upgrade) => { + if (upgrade) installs.push(upgrade); + writeRouter(dir, upgrade?.version ?? '2.0.0'); + writeFileSync( + join(dir, 'pnpm-lock.yaml'), + readFileSync(join(dir, 'pnpm-lock.yaml'), 'utf8').replaceAll('1.0.0', '2.0.0'), + ); + }, + }; + return { root, services, installs, checked }; +} + +describe('fix asked for an alternate lead', () => { + it('fixes the pack lead, declaring it beside the alternate, both at the target', async () => { + const { root, services, installs, checked } = alternateFixture(); + const result = await fix( + { cwd: root, only: 'router-dom', pack, fixer: null, tool: CLEAN_TOOL }, + services, + ); + expect(checked).toEqual([['router']]); + expect(git(root, 'branch', '--show-current')).toBe('uptide/router-2.0.0'); + // Declared in the section that declares the alternate, keeping its range style and order. + expect(readFileSync(join(root, 'package.json'), 'utf8')).toContain( + '"dependencies": {\n "alpha": "1.0.0",\n "router": "^2.0.0",\n "router-dom": "^2.0.0",\n "zeta": "1.0.0"\n }', + ); + expect(installs).toHaveLength(1); + expect(installs[0]).toMatchObject({ + name: 'router', + version: '2.0.0', + workspaces: ['.'], + files: ['package.json'], + also: [{ name: 'router-dom', version: '2.0.0' }], + }); + expect(git(root, 'log', '--format=%s', '-1', 'HEAD~1')).toBe( + 'chore: upgrade router to 2.0.0 with router-dom 2.0.0', + ); + expect(result.sites[0]).toMatchObject({ outcome: 'mechanical', rule: 'navigate-options' }); + expect(result.verification.passed).toBe(true); + }, 20000); +}); + +describe('declareLead', () => { + const manifest = (dependencies: Record, extra: object = {}) => { + const root = mkdtempSync(join(scratch, 'declare-')); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'x', dependencies, ...extra }), + ); + return root; + }; + const read = (root: string) => JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + + it.each([ + ['6.30.1', '7.18.4'], + ['^6.30.1', '^7.18.4'], + ['~6.30.0', '~7.18.4'], + ['^6', '^7'], + ])('declares the lead with the alternate range style: %s gives %s', (spec, expected) => { + const root = manifest({ 'react-router-dom': spec }); + expect(declareLead(root, 'react-router', ['react-router-dom'], '7.18.4')).toHaveLength(1); + expect(read(root).dependencies['react-router']).toBe(expected); + }); + + it('writes nothing when a workspace declares the lead, or none declares an alternate', () => { + const declared = manifest({ 'react-router': '^6.30.1', 'react-router-dom': '^6.30.1' }); + expect(declareLead(declared, 'react-router', ['react-router-dom'], '7.18.4')).toEqual([]); + const other = manifest({ zod: '^3.0.0' }); + expect(declareLead(other, 'react-router', ['react-router-dom'], '7.18.4')).toEqual([]); + expect(read(other).dependencies).toEqual({ zod: '^3.0.0' }); + }); + + it('keeps a section that is not sorted as it is, appending the lead', () => { + const root = manifest({ zod: '1.0.0', 'react-router-dom': '6.30.1' }); + declareLead(root, 'react-router', ['react-router-dom'], '7.18.4'); + expect(Object.keys(read(root).dependencies)).toEqual([ + 'zod', + 'react-router-dom', + 'react-router', + ]); + }); + + it('refuses a catalog or alias it cannot mirror, without writing', () => { + const root = manifest({ 'react-router-dom': 'catalog:' }); + expect(() => declareLead(root, 'react-router', ['react-router-dom'], '7.18.4')).toThrow( + /declare react-router 7\.18\.4 beside it by hand/, + ); + expect(read(root).dependencies).toEqual({ 'react-router-dom': 'catalog:' }); + }); +}); diff --git a/packages/core/src/fix/peer-preflight.ts b/packages/core/src/fix/peer-preflight.ts index 42b9d18..5230992 100644 --- a/packages/core/src/fix/peer-preflight.ts +++ b/packages/core/src/fix/peer-preflight.ts @@ -17,6 +17,7 @@ import { UPTIDE_COMMAND } from '../version.js'; import { type ManagerKind, packageManager } from './managers/manager.js'; import { fixDependencies } from './range-preflight.js'; import type { FixOptions, FixServices } from './run.js'; +import { standInDependencies } from './stand-in-lead.js'; import { type ResolvedTarget, resolveTarget } from './target.js'; import { assertNoDowngrades, validateVersionRanges } from './versions.js'; @@ -113,8 +114,11 @@ export async function peerPreflight( ): Promise { if (options.pinCurrentApi) return; const { installed } = fixDependencies(options.cwd, read); - const deps = [...installed.values()]; const pack = options.pack ?? activePack(options.only); + const deps = [ + ...installed.values(), + ...standInDependencies(options.cwd, pack, [...installed.values()]), + ]; const installedNames = new Set(deps.map((d) => d.name)); // A peer may reject a discovered companion even when nobody peers on the leader. if ( diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 58e2ef5..093de70 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -13,7 +13,8 @@ import { ACCEPTED_KEYS, selectLlm } from '../llm/config.js'; import { DEFAULT_MAX_COST_USD, providerFixer } from '../llm/fixer.js'; import { decisionInventory, packDecisions } from '../packs/decisions.js'; import { genericPack } from '../packs/generic.js'; -import { activePack } from '../packs/index.js'; +import { activePack, activePacks } from '../packs/index.js'; +import { leadName } from '../packs/leads.js'; import { payloadApiVersions, stripeUsageContext } from '../packs/stripe/relevance.js'; import type { MigrationPack, PackContext } from '../packs/types.js'; import { resetSharedState } from '../shared-state.js'; @@ -68,6 +69,7 @@ import { assertNoDowngrades, assertNoPinningOverrides, bumpVersions, + declareLead, install, packageManager, validateVersionRanges, @@ -199,7 +201,9 @@ export async function fix( services: FixServices = defaults, ): Promise { try { - return await fixPackage(options, services); + // Asked for by an alternate lead's name (`react-router-dom`), the pack's lead is fixed. + const only = leadName(options.only, options.pack ? [options.pack] : activePacks()); + return await fixPackage(only === options.only ? options : { ...options, only }, services); } catch (err) { resetSharedState(); throw err; @@ -405,6 +409,13 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise a.name), + target, + ); const bumps = [ bumpVersions(root, pack.name, target), ...companions.map((c) => bumpVersions(root, c.name, c.to)), @@ -412,6 +423,7 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise b.files), ...writePeerOverrides(root, peerPlan?.conflicts ?? []), ]), diff --git a/packages/core/src/fix/stand-in-lead.ts b/packages/core/src/fix/stand-in-lead.ts new file mode 100644 index 0000000..fd41f10 --- /dev/null +++ b/packages/core/src/fix/stand-in-lead.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { InstalledDependency } from '../check/companions.js'; +import { standInLeads } from '../check/stand-ins.js'; +import type { Manifest } from '../list/evidence.js'; +import type { MigrationPack } from '../packs/types.js'; + +/** + * The pack's lead where no workspace declares it but one declares an alternate lead + * (`react-router` through `react-router-dom`): at the version installed through it, in the + * workspaces that declare the alternate. Planned like a declared lead, so its release group + * moves with it; `declareLead` then writes it into the manifest. + */ +export function standInDependencies( + root: string, + pack: MigrationPack | undefined, + declared: readonly InstalledDependency[], +): InstalledDependency[] { + if (!pack?.alternateLeads?.length || declared.some((d) => d.name === pack.name)) return []; + const byWorkspace: Record> = {}; + for (const d of declared) + for (const w of d.workspaces) byWorkspace[w] = { ...byWorkspace[w], [d.name]: d.version }; + const found = standInLeads(root, Object.keys(byWorkspace), byWorkspace, [pack], [pack.name]); + const leads = new Map(); + for (const [workspace, standIns] of Object.entries(found)) { + const standIn = standIns[pack.name]; + if (!standIn) continue; + const known = leads.get(standIn.version); + if (known) known.workspaces.push(workspace); + else + leads.set(standIn.version, { + name: pack.name, + version: standIn.version, + manifest: JSON.parse(readFileSync(join(standIn.dir, 'package.json'), 'utf8')) as Manifest, + workspaces: [workspace], + }); + } + return [...leads.values()]; +} diff --git a/packages/core/src/fix/versions.ts b/packages/core/src/fix/versions.ts index 9f72ad6..8804307 100644 --- a/packages/core/src/fix/versions.ts +++ b/packages/core/src/fix/versions.ts @@ -273,6 +273,52 @@ export function bumpVersions( if (!workspaces.length) throw new Error(`no workspace declares ${name}`); return { files, workspaces, alreadyCompatible }; } +/** + * A lead the repository installs only through an alternate lead (`react-router` through + * `react-router-dom`) is declared beside the alternate, at the target, in the same section + * and range style, in each workspace that declares the alternate and not the lead: the + * upgrade then moves both. Nothing is written when some workspace already declares the lead. + * Returns the files written. + */ +export function declareLead( + root: string, + name: string, + alternates: readonly string[], + version: string, +): string[] { + if (!alternates.length) return []; + const manifests = workspacePackagesOf(root).map((workspace) => { + const file = join(root, workspace, 'package.json'); + const text = readFileSync(file, 'utf8'); + return { file, text, json: JSON.parse(text) }; + }); + if (manifests.some(({ json }) => sections.some((s) => typeof json[s]?.[name] === 'string'))) + return []; + const files: string[] = []; + for (const { file, text, json } of manifests) { + const section = (['dependencies', 'devDependencies', 'optionalDependencies'] as const).find( + (s) => alternates.some((a) => typeof json[s]?.[a] === 'string'), + ); + if (!section) continue; + const alternate = alternates.find((a) => typeof json[section][a] === 'string') as string; + const spec: string = json[section][alternate]; + if (/^(?:catalog|npm|workspace|link|file):/.test(spec)) + throw new UptideError( + 'UNSUPPORTED_VERSION_RANGE', + `${relative(root, file)}: ${section}.${alternate} is ${JSON.stringify(spec)}; declare ${name} ${version} beside it by hand, then rerun.`, + ); + const entries = Object.entries(json[section] as Record); + const keys = entries.map(([key]) => key); + const sorted = keys.every((key, i) => i === 0 || (keys[i - 1] as string) <= key); + entries.push([name, versionRange(spec, version)]); + if (sorted) entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + json[section] = Object.fromEntries(entries); + writeFileSync(file, `${JSON.stringify(json, null, text.match(/\n(\s+)"/)?.[1] ?? ' ')}\n`); + files.push(file); + } + return files; +} + export async function install(root: string, upgrade?: Upgrade): Promise { if (upgrade) return isolatedUpgrade(root, upgrade); const pm = await assertManagerAvailable(root); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20f7812..0060ee0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -190,6 +190,7 @@ export { VERIFIED_MIN_REPOS, } from './packs/contract.js'; export { activePacks, packStatus, registeredPacks } from './packs/index.js'; +export { leadName, leadNames, packLeading } from './packs/leads.js'; export { sdkApiVersion, stripeConcerns, stripePack } from './packs/stripe/index.js'; export { type FixtureResult, runFixtures } from './packs/tooling/fixtures.js'; export { diff --git a/packages/core/src/packs/contract.ts b/packages/core/src/packs/contract.ts index 5cda3a2..774b0e9 100644 --- a/packages/core/src/packs/contract.ts +++ b/packages/core/src/packs/contract.ts @@ -311,6 +311,7 @@ export interface PackSpec { inventory?: readonly string[]; requiresBuild?: boolean; releaseGroup?: readonly PackCompanion[]; + alternateLeads?: readonly PackCompanion[]; } /** @@ -330,6 +331,7 @@ export function definePack(spec: PackSpec): Pack { ...(spec.inventory ? { inventory: spec.inventory } : {}), ...(spec.requiresBuild ? { requiresBuild: true } : {}), ...(spec.releaseGroup ? { releaseGroup: spec.releaseGroup } : {}), + ...(spec.alternateLeads ? { alternateLeads: spec.alternateLeads } : {}), defaultTarget: spec.defaultTarget ?? '', supports: (from, to) => satisfies(from, spec.meta.from) && satisfies(to, spec.meta.to), transform(text, finding, context) { diff --git a/packages/core/src/packs/leads.test.ts b/packages/core/src/packs/leads.test.ts new file mode 100644 index 0000000..5d9a583 --- /dev/null +++ b/packages/core/src/packs/leads.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest'; +import { definePack } from './contract.js'; +import { leadName, leadNames, packLeading } from './leads.js'; + +const source = 'https://example.com/changelog'; +const router = definePack({ + meta: { package: 'router', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + rules: [], + instructions: '', + alternateLeads: [{ name: 'router-dom', source }], +}); +const dom = definePack({ + meta: { package: 'router-dom', from: '^1', to: '^2', sources: [], maintainer: 'test' }, + rules: [], + instructions: '', +}); + +it('resolves an alternate lead to the pack that names it, and leaves other names alone', () => { + expect(packLeading('router-dom', [router])?.name).toBe('router'); + expect(packLeading('router', [router])?.name).toBe('router'); + expect(packLeading('zod', [router])).toBeUndefined(); + expect(leadName('router-dom', [router])).toBe('router'); + expect(leadName('zod', [router])).toBe('zod'); + expect(leadNames(['router-dom', 'zod', 'router'], [router])).toEqual(['router', 'zod']); +}); + +it('prefers a pack of the name itself over one that names it as an alternate', () => { + expect(packLeading('router-dom', [router, dom])?.name).toBe('router-dom'); + expect(leadName('router-dom', [router, dom])).toBe('router-dom'); +}); diff --git a/packages/core/src/packs/leads.ts b/packages/core/src/packs/leads.ts new file mode 100644 index 0000000..bfe3dca --- /dev/null +++ b/packages/core/src/packs/leads.ts @@ -0,0 +1,25 @@ +import type { MigrationPack } from './types.js'; + +/** + * Asked for by an alternate lead's name (`react-router-dom`), the pack that names it (the one + * for `react-router`); asked for by its own name, the pack itself. + */ +export function packLeading( + name: string, + packs: readonly MigrationPack[], +): MigrationPack | undefined { + return ( + packs.find((p) => p.name === name) ?? + packs.find((p) => p.alternateLeads?.some((a) => a.name === name)) + ); +} + +/** The name a request stands for: the lead of the pack that names it as an alternate, else itself. */ +export function leadName(name: string, packs: readonly MigrationPack[]): string { + return packLeading(name, packs)?.name ?? name; +} + +/** Each name once, alternates replaced by their lead, in the order first asked for. */ +export function leadNames(names: readonly string[], packs: readonly MigrationPack[]): string[] { + return [...new Set(names.map((name) => leadName(name, packs)))]; +} diff --git a/packages/core/src/packs/tooling/pack-test.test.ts b/packages/core/src/packs/tooling/pack-test.test.ts index d0de34d..ff912e3 100644 --- a/packages/core/src/packs/tooling/pack-test.test.ts +++ b/packages/core/src/packs/tooling/pack-test.test.ts @@ -28,6 +28,13 @@ it('rejects a release group without an official source', async () => { expect(report.passed).toBe(false); }); +it('rejects an alternate lead without an official source', async () => { + const pack = { ...aiPack, alternateLeads: [{ name: 'ai-compat', source: 'http://example.com' }] }; + const report = await testPack({ dir: 'ai', pack, verification }, { root, fixturesOnly: true }); + expect(report.problems[0]).toContain('alternateLeads[0] "ai-compat"'); + expect(report.passed).toBe(false); +}); + it('passes every registered pack: each companion it names carries its source', async () => { for (const entry of registeredPacks()) { const report = await testPack(entry, { root, fixturesOnly: true }); diff --git a/packages/core/src/packs/tooling/pack-test.ts b/packages/core/src/packs/tooling/pack-test.ts index d8836a3..b24fcf6 100644 --- a/packages/core/src/packs/tooling/pack-test.ts +++ b/packages/core/src/packs/tooling/pack-test.ts @@ -275,6 +275,9 @@ export async function testPack( ...companionProblems(pack.releaseGroup, pack.name).map((p) => p.replace('companions[', 'releaseGroup['), ), + ...companionProblems(pack.alternateLeads, pack.name).map((p) => + p.replace('companions[', 'alternateLeads['), + ), ); const fixtures = runFixtures(pack, dir, { update: options.updateFixtures === true }); if (fixtures.cases.length === 0 && pack.rules.some((r) => r.rewrite || r.detect)) diff --git a/packages/core/src/packs/types.ts b/packages/core/src/packs/types.ts index 1ec4278..2bb4886 100644 --- a/packages/core/src/packs/types.ts +++ b/packages/core/src/packs/types.ts @@ -132,6 +132,14 @@ export interface MigrationPack { readonly requiresBuild?: boolean; /** Installed lockstep releases move even without a manifest edge or incompatible peer. */ readonly releaseGroup?: readonly PackCompanion[]; + /** + * Packages a repository may declare instead of this one, which install it as their own + * dependency (`react-router-dom` for `react-router`). Asked for by either name, `check` and + * `fix` use this pack; in a workspace that declares only an alternate, the lead is analyzed + * at the version installed through it, and `fix` declares the lead at the target beside it. + * Each carries the official `source` that says the alternate stands for the lead. + */ + readonly alternateLeads?: readonly PackCompanion[]; readonly defaultTarget: string; readonly rules: readonly MigrationRule[]; supports(from: string, to: string): boolean;