diff --git a/common/changes/@microsoft/rush/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json b/common/changes/@microsoft/rush/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json new file mode 100644 index 00000000000..ab75adf359c --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Make \"rush add\" and \"rush upgrade-interactive\" aware of PNPM catalogs: they write or keep \"catalog:\" references and update catalog entries in pnpm-config.json instead of writing versions.", + "type": "minor" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/npm-check-fork/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json b/common/changes/@rushstack/npm-check-fork/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json new file mode 100644 index 00000000000..c4b131bf76f --- /dev/null +++ b/common/changes/@rushstack/npm-check-fork/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/npm-check-fork", + "comment": "Honor the \"cwdPackageJson\" option, and stop mutating the default options on each call.", + "type": "patch" + } + ], + "packageName": "@rushstack/npm-check-fork", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/npm-check-fork/src/NpmCheckState.ts b/libraries/npm-check-fork/src/NpmCheckState.ts index ebcbdb7de86..ef4282e689d 100644 --- a/libraries/npm-check-fork/src/NpmCheckState.ts +++ b/libraries/npm-check-fork/src/NpmCheckState.ts @@ -8,12 +8,15 @@ import { import readPackageJson from './ReadPackageJson'; export default async function initializeState(initialOptions?: INpmCheckState): Promise { - const state: INpmCheckState = Object.assign(DefaultNpmCheckOptions, initialOptions ?? {}); + const state: INpmCheckState = { ...DefaultNpmCheckOptions, ...initialOptions }; if (state.cwd) { const cwd: string = path.resolve(state.cwd); - const pkg: INpmCheckPackageJson = readPackageJson(path.join(cwd, 'package.json')); - state.cwdPackageJson = pkg; + if (!initialOptions?.cwdPackageJson) { + const pkg: INpmCheckPackageJson = readPackageJson(path.join(cwd, 'package.json')); + state.cwdPackageJson = pkg; + } + state.cwd = cwd; } diff --git a/libraries/npm-check-fork/src/interfaces/INpmCheck.ts b/libraries/npm-check-fork/src/interfaces/INpmCheck.ts index 77f2d2172f7..69716d2de31 100644 --- a/libraries/npm-check-fork/src/interfaces/INpmCheck.ts +++ b/libraries/npm-check-fork/src/interfaces/INpmCheck.ts @@ -13,6 +13,9 @@ export interface INpmCheckPackageJson { export interface INpmCheckState { cwd: string; + /** + * The package.json contents to check. Defaults to the package.json file in `cwd`. + */ cwdPackageJson?: INpmCheckPackageJson; packages?: INpmCheckPackageSummary[]; } diff --git a/libraries/npm-check-fork/src/tests/NpmCheckState.test.ts b/libraries/npm-check-fork/src/tests/NpmCheckState.test.ts index 68ad0d0ff3f..162040f7979 100644 --- a/libraries/npm-check-fork/src/tests/NpmCheckState.test.ts +++ b/libraries/npm-check-fork/src/tests/NpmCheckState.test.ts @@ -1,4 +1,4 @@ -import type { INpmCheckState } from '../interfaces/INpmCheck'; +import type { INpmCheckPackageJson, INpmCheckState } from '../interfaces/INpmCheck'; import initializeState from '../NpmCheckState'; describe('NpmCheckState', () => { @@ -9,4 +9,18 @@ describe('NpmCheckState', () => { expect(state.cwdPackageJson).toHaveProperty('name'); expect(state.cwdPackageJson).toHaveProperty('version'); }); + + it('should use the provided package.json instead of reading it from cwd', async () => { + const cwdPackageJson: INpmCheckPackageJson = { + name: 'provided-package', + version: '1.2.3', + dependencies: { 'example-dependency': '^1.0.0' }, + devDependencies: {} + }; + const state: INpmCheckState = await initializeState({ cwd: process.cwd(), cwdPackageJson }); + expect(state.cwdPackageJson).toBe(cwdPackageJson); + + const laterState: INpmCheckState = await initializeState({ cwd: process.cwd() }); + expect(laterState.cwdPackageJson?.name).toBe('@rushstack/npm-check-fork'); + }); }); diff --git a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap index 9858a6bbf44..e28042fd3de 100644 --- a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap +++ b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap @@ -15,7 +15,7 @@ Object { "shortName": "-s", }, Object { - "description": "The name of the package which should be added as a dependency. A SemVer version specifier can be appended after an \\"@\\" sign. WARNING: Symbol characters are usually interpreted by your shell, so it's recommended to use quotes. For example, write \\"rush add --package \\"example@^1.2.3\\"\\" instead of \\"rush add --package example@^1.2.3\\". To add multiple packages, write \\"rush add --package foo --package bar\\".", + "description": "The name of the package which should be added as a dependency. A SemVer version specifier or a PNPM catalog reference (such as \\"catalog:\\" or \\"catalog:tools\\") can be appended after an \\"@\\" sign. WARNING: Symbol characters are usually interpreted by your shell, so it's recommended to use quotes. For example, write \\"rush add --package \\"example@^1.2.3\\"\\" instead of \\"rush add --package example@^1.2.3\\". To add multiple packages, write \\"rush add --package foo --package bar\\".", "environmentVariable": undefined, "kind": "StringList", "longName": "--package", diff --git a/libraries/rush-lib/src/cli/actions/AddAction.ts b/libraries/rush-lib/src/cli/actions/AddAction.ts index 5eb0784c65f..291529ad585 100644 --- a/libraries/rush-lib/src/cli/actions/AddAction.ts +++ b/libraries/rush-lib/src/cli/actions/AddAction.ts @@ -15,6 +15,7 @@ import { SemVerStyle } from '../../logic/PackageJsonUpdaterTypes'; import { getVariantAsync } from '../../api/Variants'; +import { tryParsePnpmCatalogName } from '../../logic/pnpm/PnpmCatalogReference'; const ADD_ACTION_NAME: 'add' = 'add'; export const MAKE_CONSISTENT_FLAG_NAME: '--make-consistent' = '--make-consistent'; @@ -35,7 +36,10 @@ export class AddAction extends BaseAddAndRemoveAction { ' either the latest version or a version that won\'t break the "ensureConsistentVersions" policy). If a version' + ' range (or a workspace range) is specified, the latest version in the range will be used. The version will be' + ` automatically prepended with a tilde, unless the "${EXACT_FLAG_NAME}" or "${CARET_FLAG_NAME}" flags are used.` + - ` The "${MAKE_CONSISTENT_FLAG_NAME}" flag can be used to update all packages with the dependency.` + ` The "${MAKE_CONSISTENT_FLAG_NAME}" flag can be used to update all packages with the dependency.` + + ' With PNPM catalogs, a catalog reference such as "catalog:" is written instead of a version when the' + + ' catalog entry matches the requested version (or no version is specified); with' + + ` "${MAKE_CONSISTENT_FLAG_NAME}", the catalog entry is updated.` ].join('\n'); super({ actionName: ADD_ACTION_NAME, @@ -46,7 +50,8 @@ export class AddAction extends BaseAddAndRemoveAction { allFlagDescription: 'If specified, the dependency will be added to all projects.', packageNameListParameterDescription: 'The name of the package which should be added as a dependency.' + - ' A SemVer version specifier can be appended after an "@" sign. WARNING: Symbol characters' + + ' A SemVer version specifier or a PNPM catalog reference (such as "catalog:" or "catalog:tools")' + + ' can be appended after an "@" sign. WARNING: Symbol characters' + " are usually interpreted by your shell, so it's recommended to use quotes." + ` For example, write "rush add ${PACKAGE_PARAMETER_NAME} "example@^1.2.3"" instead of "rush add ${PACKAGE_PARAMETER_NAME} example@^1.2.3".` + ` To add multiple packages, write "rush add ${PACKAGE_PARAMETER_NAME} foo ${PACKAGE_PARAMETER_NAME} bar".` @@ -115,7 +120,14 @@ export class AddAction extends BaseAddAndRemoveAction { throw new Error(`The package name "${packageName}" is not valid.`); } - if (version && version !== 'latest') { + if (version && tryParsePnpmCatalogName(version) !== undefined) { + if (!this.rushConfiguration.isPnpm) { + throw new Error( + `The version specifier "${version}" for "${packageName}" references a PNPM catalog, ` + + 'but catalogs are only supported when the package manager is PNPM.' + ); + } + } else if (version && version !== 'latest') { const specifier: DependencySpecifier = new DependencySpecifier(packageName, version); if (!semver.validRange(specifier.versionSpecifier) && !semver.valid(specifier.versionSpecifier)) { throw new Error(`The SemVer specifier "${version}" is not valid.`); diff --git a/libraries/rush-lib/src/cli/actions/test/AddAction.test.ts b/libraries/rush-lib/src/cli/actions/test/AddAction.test.ts index e41934ed1f0..026a44b9ee5 100644 --- a/libraries/rush-lib/src/cli/actions/test/AddAction.test.ts +++ b/libraries/rush-lib/src/cli/actions/test/AddAction.test.ts @@ -4,6 +4,7 @@ import '../../test/mockRushCommandLineParser'; import { LockFile } from '@rushstack/node-core-library'; +import { AnsiEscape } from '@rushstack/terminal'; import { PackageJsonUpdater } from '../../../logic/PackageJsonUpdater'; import type { IPackageJsonUpdaterRushAddOptions } from '../../../logic/PackageJsonUpdaterTypes'; @@ -105,5 +106,64 @@ describe(AddAction.name, () => { `); }); }); + + describe("'add' action with PNPM catalog references", () => { + it('passes explicit catalog references through', async () => { + const startPath: string = `${__dirname}/../../../logic/test/repoWithCatalogs`; + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: startPath }); + jest.spyOn(process, 'cwd').mockReturnValue(`${startPath}/c`); + + process.argv = [ + 'pretend-this-is-node.exe', + 'pretend-this-is-rush', + 'add', + '-p', + 'semver@catalog:', + '-p', + 'typescript@catalog:tools' + ]; + + await expect(parser.executeAsync()).resolves.toEqual(true); + expect(doRushAddMock).toHaveBeenCalledTimes(1); + const doRushAddOptions: IPackageJsonUpdaterRushAddOptions = doRushAddMock.mock.calls[0][0]; + expect(doRushAddOptions.projects.map(({ packageName }) => packageName)).toEqual(['c']); + expect(doRushAddOptions.packagesToUpdate).toMatchInlineSnapshot(` + Array [ + Object { + "packageName": "semver", + "rangeStyle": "passthrough", + "version": "catalog:", + }, + Object { + "packageName": "typescript", + "rangeStyle": "passthrough", + "version": "catalog:tools", + }, + ] + `); + }); + + it('rejects catalog references when the package manager is not PNPM', async () => { + const consoleErrorMock: jest.SpyInstance = jest.spyOn(console, 'error').mockImplementation(); + const startPath: string = `${__dirname}/addRepo`; + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: startPath }); + jest.spyOn(process, 'cwd').mockReturnValue(`${startPath}/a`); + + process.argv = ['pretend-this-is-node.exe', 'pretend-this-is-rush', 'add', '-p', 'assert@catalog:']; + + await parser.executeAsync(); + expect(process.exitCode).toEqual(1); + expect(doRushAddMock).not.toHaveBeenCalled(); + // The error message is colorized and word-wrapped + const errorOutput: string = AnsiEscape.removeCodes(consoleErrorMock.mock.calls.join('\n')).replace( + /\s+/g, + ' ' + ); + expect(errorOutput).toContain( + 'The version specifier "catalog:" for "assert" references a PNPM catalog, ' + + 'but catalogs are only supported when the package manager is PNPM.' + ); + }); + }); }); }); diff --git a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap index 0d738cff90c..6d2357c00e9 100644 --- a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap +++ b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap @@ -99,7 +99,10 @@ either the latest version or a version that won't break the is specified, the latest version in the range will be used. The version will be automatically prepended with a tilde, unless the \\"--exact\\" or \\"--caret\\" flags are used. The \\"--make-consistent\\" flag can be used to update all -packages with the dependency. +packages with the dependency. With PNPM catalogs, a catalog reference such as +\\"catalog:\\" is written instead of a version when the catalog entry matches the +requested version (or no version is specified); with \\"--make-consistent\\", the +catalog entry is updated. Optional arguments: -h, --help Show this help message and exit. @@ -107,14 +110,15 @@ Optional arguments: run after updating the package.json files. -p PACKAGE, --package PACKAGE The name of the package which should be added as a - dependency. A SemVer version specifier can be - appended after an \\"@\\" sign. WARNING: Symbol - characters are usually interpreted by your shell, so - it's recommended to use quotes. For example, write - \\"rush add --package \\"example@^1.2.3\\"\\" instead of - \\"rush add --package example@^1.2.3\\". To add multiple - packages, write \\"rush add --package foo --package - bar\\". + dependency. A SemVer version specifier or a PNPM + catalog reference (such as \\"catalog:\\" or + \\"catalog:tools\\") can be appended after an \\"@\\" sign. + WARNING: Symbol characters are usually interpreted by + your shell, so it's recommended to use quotes. For + example, write \\"rush add --package \\"example@^1.2.3\\"\\" + instead of \\"rush add --package example@^1.2.3\\". To + add multiple packages, write \\"rush add --package foo + --package bar\\". --all If specified, the dependency will be added to all projects. --variant VARIANT Run command using a variant installation diff --git a/libraries/rush-lib/src/logic/InteractiveUpgrader.ts b/libraries/rush-lib/src/logic/InteractiveUpgrader.ts index ff7784fadd3..fdbc79b724b 100644 --- a/libraries/rush-lib/src/logic/InteractiveUpgrader.ts +++ b/libraries/rush-lib/src/logic/InteractiveUpgrader.ts @@ -2,11 +2,16 @@ // See LICENSE in the project root for license information. import { NpmCheck, type INpmCheckState, type INpmCheckPackageSummary } from '@rushstack/npm-check-fork'; +import type { IPackageJson } from '@rushstack/node-core-library'; import { Colorize } from '@rushstack/terminal'; import type { RushConfiguration } from '../api/RushConfiguration'; import { upgradeInteractive, type IDepsToUpgradeAnswers } from '../utilities/InteractiveUpgradeUI'; import type { RushConfigurationProject } from '../api/RushConfigurationProject'; +import { getEffectivePnpmCatalogs, type PnpmCatalogs } from './pnpm/EffectivePnpmOptions'; +import { tryResolvePnpmCatalogReference, type IPnpmCatalogReference } from './pnpm/PnpmCatalogReference'; + +type NpmCheckPackageJson = NonNullable; interface IUpgradeInteractiveDeps { projects: RushConfigurationProject[]; @@ -68,9 +73,57 @@ export class InteractiveUpgrader { const { projectFolder } = rushProject; const currentState: INpmCheckState = await NpmCheck({ - cwd: projectFolder + cwd: projectFolder, + cwdPackageJson: this.#tryGetPackageJsonWithResolvedCatalogReferences(rushProject) }); return currentState.packages ?? []; } + + /** + * NpmCheck skips non-SemVer specifiers such as `catalog:`, so this returns a copy of package.json with + * catalog references resolved, or `undefined` if there are none. + */ + #tryGetPackageJsonWithResolvedCatalogReferences( + rushProject: RushConfigurationProject + ): NpmCheckPackageJson | undefined { + const catalogs: PnpmCatalogs | undefined = getEffectivePnpmCatalogs( + rushProject.subspace, + this.#rushConfiguration + ); + if (!catalogs) { + return undefined; + } + + let hasResolvedReferences: boolean = false; + const resolveCatalogReferences: ( + dependencies: Record | undefined + ) => Record = (dependencies: Record | undefined) => { + const result: Record = {}; + for (const [packageName, versionSpecifier] of Object.entries(dependencies ?? {})) { + const catalogReference: IPnpmCatalogReference | undefined = tryResolvePnpmCatalogReference( + packageName, + versionSpecifier, + catalogs + ); + if (catalogReference?.resolvedVersion !== undefined) { + result[packageName] = catalogReference.resolvedVersion; + hasResolvedReferences = true; + } else { + result[packageName] = versionSpecifier; + } + } + + return result; + }; + + const packageJson: IPackageJson = rushProject.packageJson; + const packageJsonWithResolvedReferences: NpmCheckPackageJson = { + ...packageJson, + dependencies: resolveCatalogReferences(packageJson.dependencies), + devDependencies: resolveCatalogReferences(packageJson.devDependencies) + }; + + return hasResolvedReferences ? packageJsonWithResolvedReferences : undefined; + } } diff --git a/libraries/rush-lib/src/logic/PackageJsonUpdater.ts b/libraries/rush-lib/src/logic/PackageJsonUpdater.ts index 1884a103af9..746d3a49708 100644 --- a/libraries/rush-lib/src/logic/PackageJsonUpdater.ts +++ b/libraries/rush-lib/src/logic/PackageJsonUpdater.ts @@ -31,7 +31,17 @@ import { } from './PackageJsonUpdaterTypes'; import type { Subspace } from '../api/Subspace'; import { MAKE_CONSISTENT_FLAG_NAME } from '../cli/actions/AddAction'; -import { tryParsePnpmCatalogName } from './pnpm/PnpmCatalogReference'; +import { + DEFAULT_PNPM_CATALOG_NAME, + formatPnpmCatalogSpecifier, + type IPnpmCatalogReference, + PNPM_CATALOG_PROTOCOL, + tryGetPnpmCatalogVersion, + tryParsePnpmCatalogName, + tryResolvePnpmCatalogReference +} from './pnpm/PnpmCatalogReference'; +import type { PnpmCatalogs } from './pnpm/EffectivePnpmOptions'; +import { PendingPnpmCatalogUpdates } from './pnpm/PendingPnpmCatalogUpdates'; /** * Options for adding a dependency to a particular project. @@ -96,6 +106,24 @@ export interface IUpdateProjectOptions extends IBaseUpdateProjectOptions { */ export interface IRemoveProjectOptions extends IBaseUpdateProjectOptions {} +interface IRushAddSelectedVersion { + /** + * Never a catalog reference. + */ + version: string; + /** + * What to write to package.json: `version` or a catalog reference. + */ + versionSpecifier: string; + isExplicitCatalogReference: boolean; +} + +interface IUpgradeProjectUpdates { + project: VersionMismatchFinderEntity; + dependenciesToUpdate: Record; + devDependenciesToUpdate: Record; +} + /** * A helper class for managing the dependencies of various package.json files. * @internal @@ -120,6 +148,9 @@ export class PackageJsonUpdater { /** * Upgrade dependencies to a particular project, or across specified projects. This is the core business logic for * "rush upgrade-interactive". + * + * @remarks + * Like `pnpm update`, a dependency referenced through a PNPM catalog is upgraded by updating the catalog entry. */ public async doRushUpgradeAsync(options: IPackageJsonUpdaterRushUpgradeOptions): Promise { const { projects, packagesToAdd, updateOtherPackages, skipUpdate, debugInstall, variant } = options; @@ -136,12 +167,46 @@ export class PackageJsonUpdater { commonVersionsConfiguration }: IDependencyAnalysis = dependencyAnalyzer.getAnalysis(undefined, variant, false); - const dependenciesToUpdate: Record = {}; - const devDependenciesToUpdate: Record = {}; - const peerDependenciesToUpdate: Record = {}; + const pendingCatalogUpdates: PendingPnpmCatalogUpdates = new PendingPnpmCatalogUpdates( + this.#rushConfiguration + ); + + const projectUpdatesByProject: Map = new Map(); + for (const project of projects) { + projectUpdatesByProject.set(project, { + project: new VersionMismatchFinderProject(project), + dependenciesToUpdate: {}, + devDependenciesToUpdate: {} + }); + } + + // Resolved versions, never catalog references + const versionByPackageName: Map = new Map(); for (const { moduleName, latest: latestVersion, packageJson, devDependency } of packagesToAdd) { - const inferredRangeStyle: SemVerStyle = this.#cheaplyDetectSemVerRangeStyle(packageJson); + const dependencyType: DependencyType = devDependency ? DependencyType.Dev : DependencyType.Regular; + + const catalogReferenceByProject: Map = new Map(); + for (const [project, { project: mismatchFinderProject }] of projectUpdatesByProject) { + const catalogReference: IPnpmCatalogReference | undefined = this.#tryGetResolvedCatalogReference( + mismatchFinderProject, + moduleName, + dependencyType, + pendingCatalogUpdates.getCatalogs(project.subspace) + ); + if (catalogReference) { + catalogReferenceByProject.set(project, catalogReference); + } + } + + // Keep the range style of the catalog entry + const catalogVersions: string[] = Array.from( + catalogReferenceByProject.values(), + ({ resolvedVersion }) => resolvedVersion! + ); + const inferredRangeStyle: SemVerStyle = this.#cheaplyDetectSemVerRangeStyle( + catalogVersions[0] ?? packageJson + ); const implicitlyPreferredVersion: string | undefined = implicitlyPreferredVersionByPackageName.get(moduleName); @@ -158,72 +223,107 @@ export class PackageJsonUpdater { commonVersionsConfiguration.ensureConsistentVersions ); - if (devDependency) { - devDependenciesToUpdate[moduleName] = version; - } else { - dependenciesToUpdate[moduleName] = version; - } + versionByPackageName.set(moduleName, version); this.#terminal.writeLine( Colorize.green(`Updating projects to use `) + moduleName + '@' + Colorize.cyan(version) ); this.#terminal.writeLine(); - const existingSpecifiedVersions: Set | undefined = allVersionsByPackageName.get(moduleName); - if ( - existingSpecifiedVersions && - !existingSpecifiedVersions.has(version) && - commonVersionsConfiguration.ensureConsistentVersions && - !updateOtherPackages - ) { - // There are existing versions, and the version we're going to use is not one of them, and this repo - // requires consistent versions, and we aren't going to update other packages, so we can't proceed. + for (const [project, projectUpdates] of projectUpdatesByProject) { + const catalogReference: IPnpmCatalogReference | undefined = catalogReferenceByProject.get(project); + if (catalogReference) { + pendingCatalogUpdates.setVersion( + project.subspace, + catalogReference.catalogName, + moduleName, + version + ); + this.#terminal.writeLine( + `"${project.packageName}" references "${moduleName}" through the PNPM catalog ` + + `"${formatPnpmCatalogSpecifier(catalogReference.catalogName)}", so the catalog entry will be ` + + 'updated instead of package.json.' + ); + } else if (devDependency) { + projectUpdates.devDependenciesToUpdate[moduleName] = version; + } else { + projectUpdates.dependenciesToUpdate[moduleName] = version; + } + } - const existingVersionList: string = Array.from(existingSpecifiedVersions).join(', '); - throw new Error( - `Adding '${moduleName}@${version}' ` + - `causes mismatched dependencies. Use the "--make-consistent" flag to update other packages to use ` + - `this version, or try specify one of the existing versions (${existingVersionList}).` - ); + if (commonVersionsConfiguration.ensureConsistentVersions && !updateOtherPackages) { + if (catalogReferenceByProject.size > 0) { + // Updating a catalog entry also changes the version for every other project that references the + // entry, so check the versions that the projects will use after the change. + const allowedAlternativeVersions: ReadonlySet = new Set( + commonVersionsConfiguration.allowedAlternativeVersions.get(moduleName) + ); + const otherVersions: string[] = Array.from( + this.#getVersionsAfterUpgrade( + moduleName, + version, + dependencyType, + projectUpdatesByProject, + pendingCatalogUpdates + ) + ).filter( + (otherVersion: string) => + otherVersion !== version && !allowedAlternativeVersions.has(otherVersion) + ); + if (otherVersions.length > 0) { + this.#throwMismatchedVersionsError(moduleName, version, otherVersions); + } + } else { + const existingSpecifiedVersions: Set | undefined = allVersionsByPackageName.get(moduleName); + if (existingSpecifiedVersions && !existingSpecifiedVersions.has(version)) { + // There are existing versions, and the version we're going to use is not one of them, and this repo + // requires consistent versions, and we aren't going to update other packages, so we can't proceed. + this.#throwMismatchedVersionsError(moduleName, version, existingSpecifiedVersions); + } + } } } const allPackageUpdates: Map = new Map(); - const allDependenciesToUpdate: [string, string][] = [ - ...Object.entries(dependenciesToUpdate), - ...Object.entries(devDependenciesToUpdate), - ...Object.entries(peerDependenciesToUpdate) - ]; - for (const project of projects) { - const mismatchFinderProject: VersionMismatchFinderProject = new VersionMismatchFinderProject(project); + for (const { + project, + dependenciesToUpdate, + devDependenciesToUpdate + } of projectUpdatesByProject.values()) { + allPackageUpdates.set(project.filePath, project); - const currentProjectDepUpdate: IUpdateProjectOptions = { - project: mismatchFinderProject, + this.updateProject({ + project, dependenciesToAddOrUpdateOrRemove: dependenciesToUpdate, dependencyType: DependencyType.Regular - }; - - const currentProjectDevDepUpdate: IUpdateProjectOptions = { - project: mismatchFinderProject, + }); + this.updateProject({ + project, dependenciesToAddOrUpdateOrRemove: devDependenciesToUpdate, dependencyType: DependencyType.Dev - }; - - allPackageUpdates.set(mismatchFinderProject.filePath, mismatchFinderProject); - - this.updateProject(currentProjectDepUpdate); - this.updateProject(currentProjectDevDepUpdate); + }); } if (updateOtherPackages) { - const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( - this.#rushConfiguration, - options - ); - for (const update of this.#getUpdates(mismatchFinder, allDependenciesToUpdate)) { - this.updateProject(update); - allPackageUpdates.set(update.project.filePath, update.project); + for (const subspace of this.#rushConfiguration.getSubspacesForProjects(projects)) { + const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( + this.#rushConfiguration, + { + subspace, + variant, + pnpmCatalogs: pendingCatalogUpdates.getCatalogs(subspace) + } + ); + for (const update of this.#getUpdates( + mismatchFinder, + versionByPackageName, + subspace, + pendingCatalogUpdates + )) { + this.updateProject(update); + allPackageUpdates.set(update.project.filePath, update.project); + } } } @@ -238,24 +338,27 @@ export class PackageJsonUpdater { { concurrency: 10 } ); - if (!skipUpdate) { - if (this.#rushConfiguration.subspacesFeatureEnabled) { - const subspaceSet: ReadonlySet = this.#rushConfiguration.getSubspacesForProjects( - options.projects - ); - for (const subspace of subspaceSet) { - await this.#doUpdateAsync(debugInstall, subspace, variant); - } - } else { - await this.#doUpdateAsync(debugInstall, this.#rushConfiguration.defaultSubspace, variant); - } + const subspacesToUpdate: ReadonlySet = skipUpdate + ? new Set() + : this.#getSubspacesToUpdate(projects); + await pendingCatalogUpdates.saveAsync(this.#terminal, subspacesToUpdate); + + for (const subspace of subspacesToUpdate) { + await this.#doUpdateAsync(debugInstall, subspace, variant); } } public async doRushUpdateAsync(options: IPackageJsonUpdaterRushBaseUpdateOptions): Promise { + const pendingCatalogUpdates: PendingPnpmCatalogUpdates = new PendingPnpmCatalogUpdates( + this.#rushConfiguration + ); + let allPackageUpdates: IUpdateProjectOptions[] = []; if (options.actionName === 'add') { - allPackageUpdates = await this.#doRushAddAsync(options as IPackageJsonUpdaterRushAddOptions); + allPackageUpdates = await this.#doRushAddAsync( + options as IPackageJsonUpdaterRushAddOptions, + pendingCatalogUpdates + ); } else if (options.actionName === 'remove') { allPackageUpdates = await this.#doRushRemoveAsync(options as IPackageJsonUpdaterRushRemoveOptions); } else { @@ -274,17 +377,21 @@ export class PackageJsonUpdater { { concurrency: 10 } ); - if (!skipUpdate) { - if (this.#rushConfiguration.subspacesFeatureEnabled) { - const subspaceSet: ReadonlySet = this.#rushConfiguration.getSubspacesForProjects( - options.projects - ); - for (const subspace of subspaceSet) { - await this.#doUpdateAsync(debugInstall, subspace, variant); - } - } else { - await this.#doUpdateAsync(debugInstall, this.#rushConfiguration.defaultSubspace, variant); - } + const subspacesToUpdate: ReadonlySet = skipUpdate + ? new Set() + : this.#getSubspacesToUpdate(options.projects); + await pendingCatalogUpdates.saveAsync(this.#terminal, subspacesToUpdate); + + for (const subspace of subspacesToUpdate) { + await this.#doUpdateAsync(debugInstall, subspace, variant); + } + } + + #getSubspacesToUpdate(projects: Iterable): ReadonlySet { + if (this.#rushConfiguration.subspacesFeatureEnabled) { + return this.#rushConfiguration.getSubspacesForProjects(projects); + } else { + return new Set([this.#rushConfiguration.defaultSubspace]); } } @@ -333,7 +440,10 @@ export class PackageJsonUpdater { /** * Adds a dependency to a particular project. The core business logic for "rush add". */ - async #doRushAddAsync(options: IPackageJsonUpdaterRushAddOptions): Promise { + async #doRushAddAsync( + options: IPackageJsonUpdaterRushAddOptions, + pendingCatalogUpdates: PendingPnpmCatalogUpdates + ): Promise { const { projects } = options; const { DependencyAnalyzer } = await import( @@ -348,7 +458,9 @@ export class PackageJsonUpdater { const subspaceSet: ReadonlySet = this.#rushConfiguration.getSubspacesForProjects(projects); for (const subspace of subspaceSet) { // Projects for this subspace - allPackageUpdates.push(...(await this.#updateProjectsAsync(subspace, dependencyAnalyzer, options))); + allPackageUpdates.push( + ...(await this.#updateProjectsAsync(subspace, dependencyAnalyzer, options, pendingCatalogUpdates)) + ); } return allPackageUpdates; @@ -357,7 +469,8 @@ export class PackageJsonUpdater { async #updateProjectsAsync( subspace: Subspace, dependencyAnalyzer: DependencyAnalyzer, - options: IPackageJsonUpdaterRushAddOptions + options: IPackageJsonUpdaterRushAddOptions, + pendingCatalogUpdates: PendingPnpmCatalogUpdates ): Promise { const { projects, packagesToUpdate, devDependency, peerDependency, updateOtherPackages, variant } = options; @@ -367,39 +480,62 @@ export class PackageJsonUpdater { (project) => project.subspace === subspace ); - const { - allVersionsByPackageName, - implicitlyPreferredVersionByPackageName, - commonVersionsConfiguration - }: IDependencyAnalysis = dependencyAnalyzer.getAnalysis(subspace, variant, options.actionName === 'add'); + const analysis: IDependencyAnalysis = dependencyAnalyzer.getAnalysis( + subspace, + variant, + options.actionName === 'add' + ); + const { allVersionsByPackageName, commonVersionsConfiguration } = analysis; - this.#terminal.writeLine(); - const dependenciesToAddOrUpdate: Record = {}; - for (const { packageName, version: initialVersion, rangeStyle } of packagesToUpdate) { - const implicitlyPreferredVersion: string | undefined = - implicitlyPreferredVersionByPackageName.get(packageName); + const dependencyType: DependencyType | undefined = devDependency + ? DependencyType.Dev + : peerDependency + ? DependencyType.Peer + : undefined; - const explicitlyPreferredVersion: string | undefined = - commonVersionsConfiguration.preferredVersions.get(packageName); + // One record per project, because projects that use a catalog reference keep it + const projectUpdates: IUpdateProjectOptions[] = subspaceProjects.map((project) => ({ + project: new VersionMismatchFinderProject(project), + dependenciesToAddOrUpdateOrRemove: {}, + dependencyType + })); - const version: string = await this.#getNormalizedVersionSpecAsync( + // Resolved versions, never catalog references + const versionByPackageName: Map = new Map(); + + this.#terminal.writeLine(); + for (const packageToUpdate of packagesToUpdate) { + const { packageName } = packageToUpdate; + const selectedVersion: IRushAddSelectedVersion = await this.#getRushAddVersionAsync( + subspace, subspaceProjects, - packageName, - initialVersion, - implicitlyPreferredVersion, - explicitlyPreferredVersion, - rangeStyle, - commonVersionsConfiguration.ensureConsistentVersions + projectUpdates, + packageToUpdate, + analysis, + updateOtherPackages, + pendingCatalogUpdates ); + const { version, versionSpecifier } = selectedVersion; - dependenciesToAddOrUpdate[packageName] = version; + versionByPackageName.set(packageName, version); this.#terminal.writeLine( Colorize.green('Updating projects to use '), `${packageName}@`, - Colorize.cyan(version) + Colorize.cyan(versionSpecifier === version ? version : `${versionSpecifier} (${version})`) ); this.#terminal.writeLine(); + for (const projectUpdate of projectUpdates) { + projectUpdate.dependenciesToAddOrUpdateOrRemove[packageName] = this.#getRushAddVersionSpecifier( + projectUpdate, + packageName, + selectedVersion, + subspace, + updateOtherPackages, + pendingCatalogUpdates + ); + } + const existingSpecifiedVersions: Set | undefined = allVersionsByPackageName.get(packageName); if ( existingSpecifiedVersions && @@ -409,57 +545,505 @@ export class PackageJsonUpdater { ) { // There are existing versions, and the version we're going to use is not one of them, and this repo // requires consistent versions, and we aren't going to update other packages, so we can't proceed. + this.#throwMismatchedVersionsError(packageName, version, existingSpecifiedVersions); + } + } + + this.updateProjects(projectUpdates); + + // we need to do a mismatch check + let otherPackageUpdates: IUpdateProjectOptions[] = []; + if (updateOtherPackages) { + const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( + this.#rushConfiguration, + { + subspace, + variant, + pnpmCatalogs: pendingCatalogUpdates.getCatalogs(subspace) + } + ); + otherPackageUpdates = this.#getUpdates( + mismatchFinder, + versionByPackageName, + subspace, + pendingCatalogUpdates + ); + this.updateProjects(otherPackageUpdates); + } + + return [...projectUpdates, ...otherPackageUpdates]; + } + + /** + * Like PNPM's `catalogMode: prefer`, selects a catalog reference when a catalog entry matches the requested + * version (or no version was requested). With "--make-consistent", the catalog entry is updated instead. + */ + async #getRushAddVersionAsync( + subspace: Subspace, + subspaceProjects: RushConfigurationProject[], + projectUpdates: IUpdateProjectOptions[], + { packageName, version: initialVersion, rangeStyle }: IPackageForRushAdd, + { + implicitlyPreferredVersionByPackageName, + commonVersionsConfiguration, + catalogNamesByPackageName + }: IDependencyAnalysis, + updateOtherPackages: boolean, + pendingCatalogUpdates: PendingPnpmCatalogUpdates + ): Promise { + const catalogs: PnpmCatalogs | undefined = pendingCatalogUpdates.getCatalogs(subspace); + const getCatalogVersion: (catalogName: string) => string | undefined = (catalogName: string) => + tryGetPnpmCatalogVersion(catalogs, catalogName, packageName); + + const explicitCatalogName: string | undefined = + initialVersion === undefined ? undefined : tryParsePnpmCatalogName(initialVersion); + if (explicitCatalogName !== undefined) { + const versionSpecifier: string = formatPnpmCatalogSpecifier(explicitCatalogName); + const version: string = this.#getExplicitCatalogVersion( + subspace, + catalogs, + explicitCatalogName, + packageName, + initialVersion!, + pendingCatalogUpdates + ); + this.#terminal.writeLine( + Colorize.green('Assigning "') + + Colorize.cyan(versionSpecifier) + + Colorize.green(`" for "${packageName}", which is "${version}" in the PNPM catalog.`) + ); + return { + version, + versionSpecifier, + isExplicitCatalogReference: true + }; + } + + const candidateCatalogNames: string[] = this.#getCatalogNamesWithPackage( + catalogs, + packageName, + subspaceProjects + ); + // Prefer the catalogs that the projects already reference the package through, then the catalogs that + // other projects reference it through + const preferredCatalogNames: Iterable[] = [ + this.#getReferencedCatalogNames(projectUpdates, packageName), + catalogNamesByPackageName.get(packageName) ?? [] + ]; + + if (candidateCatalogNames.length > 0) { + const matchingCatalogNames: string[] = + initialVersion === undefined + ? candidateCatalogNames + : candidateCatalogNames.filter((catalogName) => getCatalogVersion(catalogName) === initialVersion); + const catalogName: string | undefined = this.#trySelectCatalogName( + matchingCatalogNames, + preferredCatalogNames + ); + if (catalogName !== undefined) { + return this.#getCatalogSelectedVersion(packageName, catalogName, getCatalogVersion(catalogName)!); + } + } + + const implicitlyPreferredVersion: string | undefined = + implicitlyPreferredVersionByPackageName.get(packageName); + const explicitlyPreferredVersion: string | undefined = + commonVersionsConfiguration.preferredVersions.get(packageName); + + const version: string = await this.#getNormalizedVersionSpecAsync( + subspaceProjects, + packageName, + initialVersion, + implicitlyPreferredVersion, + explicitlyPreferredVersion, + rangeStyle, + commonVersionsConfiguration.ensureConsistentVersions + ); - const existingVersionList: string = Array.from(existingSpecifiedVersions).join(', '); - throw new Error( - `Adding '${packageName}@${version}' ` + - `causes mismatched dependencies. Use the "${MAKE_CONSISTENT_FLAG_NAME}" flag to update other packages to use ` + - `this version, or try specify one of the existing versions (${existingVersionList}).` + const literalVersion: IRushAddSelectedVersion = { + version, + versionSpecifier: version, + isExplicitCatalogReference: false + }; + if (candidateCatalogNames.length === 0) { + return literalVersion; + } + + const catalogNamesWithVersion: string[] = candidateCatalogNames.filter( + (catalogName) => getCatalogVersion(catalogName) === version + ); + const catalogName: string | undefined = this.#trySelectCatalogName( + catalogNamesWithVersion, + preferredCatalogNames + ); + if (catalogName !== undefined) { + return this.#getCatalogSelectedVersion(packageName, catalogName, version); + } + + const canUpdateCatalog: boolean = + catalogNamesWithVersion.length === 0 && initialVersion !== undefined && updateOtherPackages; + if (canUpdateCatalog) { + const catalogNameToUpdate: string | undefined = this.#trySelectCatalogName( + candidateCatalogNames, + preferredCatalogNames + ); + if (catalogNameToUpdate !== undefined) { + pendingCatalogUpdates.setVersion(subspace, catalogNameToUpdate, packageName, version); + this.#terminal.writeLine( + `Updating "${packageName}" in the PNPM catalog "${formatPnpmCatalogSpecifier(catalogNameToUpdate)}" ` + + `to "${version}" because the "${MAKE_CONSISTENT_FLAG_NAME}" flag was specified.` ); + return this.#getCatalogSelectedVersion(packageName, catalogNameToUpdate, version); } } - const allPackageUpdates: IUpdateProjectOptions[] = []; + const catalogList: string = candidateCatalogNames + .map((name) => `"${formatPnpmCatalogSpecifier(name)}" (${getCatalogVersion(name)})`) + .join(', '); + const isVersionMismatch: boolean = catalogNamesWithVersion.length === 0 && initialVersion !== undefined; + const reason: string = isVersionMismatch + ? `"${version}" for "${packageName}" is different from the PNPM ` + + (candidateCatalogNames.length === 1 + ? `catalog entry ${catalogList}` + : `catalog entries ${catalogList}`) + : `Rush could not choose between the PNPM catalogs ${catalogList} for "${packageName}"`; + const catalogSpecifierExample: string = + candidateCatalogNames.length === 1 + ? formatPnpmCatalogSpecifier(candidateCatalogNames[0]) + : `${PNPM_CATALOG_PROTOCOL}`; + this.#terminal.writeLine( + Colorize.yellow( + `Note: ${reason}, so the version "${version}" is assigned instead of a catalog reference. ` + + `To reference a catalog, specify "${packageName}@${catalogSpecifierExample}"` + + (isVersionMismatch && !updateOtherPackages + ? `, or use the "${MAKE_CONSISTENT_FLAG_NAME}" flag to update the catalog entry.` + : '.') + ) + ); + return literalVersion; + } - for (const project of subspaceProjects) { - const currentProjectUpdate: IUpdateProjectOptions = { - project: new VersionMismatchFinderProject(project), - dependenciesToAddOrUpdateOrRemove: dependenciesToAddOrUpdate, - dependencyType: devDependency ? DependencyType.Dev : peerDependency ? DependencyType.Peer : undefined - }; - this.updateProject(currentProjectUpdate); + #getCatalogSelectedVersion( + packageName: string, + catalogName: string, + version: string + ): IRushAddSelectedVersion { + const versionSpecifier: string = formatPnpmCatalogSpecifier(catalogName); + this.#terminal.writeLine( + Colorize.green('Assigning "') + + Colorize.cyan(versionSpecifier) + + Colorize.green(`" for "${packageName}" because it is "${version}" in the PNPM catalog.`) + ); + return { version, versionSpecifier, isExplicitCatalogReference: false }; + } - let otherPackageUpdates: IUpdateProjectOptions[] = []; + #getExplicitCatalogVersion( + subspace: Subspace, + catalogs: PnpmCatalogs | undefined, + catalogName: string, + packageName: string, + versionSpecifier: string, + pendingCatalogUpdates: PendingPnpmCatalogUpdates + ): string { + if (!this.#rushConfiguration.isPnpm) { + throw new Error( + `The version specifier "${versionSpecifier}" for "${packageName}" references a PNPM catalog, ` + + 'but catalogs are only supported when the package manager is PNPM.' + ); + } - // we need to do a mismatch check - if (updateOtherPackages) { - const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( - this.#rushConfiguration, - { - subspace, - variant - } + const version: string | undefined = tryGetPnpmCatalogVersion(catalogs, catalogName, packageName); + if (version !== undefined) { + return version; + } + + const pnpmConfigFilePath: string = pendingCatalogUpdates.getPnpmConfigFilePath(subspace); + if (!catalogs || !Object.prototype.hasOwnProperty.call(catalogs, catalogName)) { + throw new Error( + `The PNPM catalog "${catalogName}" that "${packageName}@${versionSpecifier}" references is not ` + + `defined in the "globalCatalogs" setting of ${pnpmConfigFilePath}.` + ); + } + + throw new Error( + `The PNPM catalog "${catalogName}" in ${pnpmConfigFilePath} does not have an entry for ` + + `"${packageName}". Add "${packageName}" to the catalog, or specify a version instead of ` + + `"${versionSpecifier}".` + ); + } + + /** + * Local projects linked with "workspace:" are never implicitly referenced through a catalog. + */ + #getCatalogNamesWithPackage( + catalogs: PnpmCatalogs | undefined, + packageName: string, + projects: RushConfigurationProject[] + ): string[] { + if (!catalogs) { + return []; + } + + if ( + this.#rushConfiguration.projectsByName.has(packageName) && + projects.some((project) => !project.decoupledLocalDependencies.has(packageName)) + ) { + return []; + } + + return Object.keys(catalogs).filter( + (catalogName) => tryGetPnpmCatalogVersion(catalogs, catalogName, packageName) !== undefined + ); + } + + #getReferencedCatalogNames(projectUpdates: IUpdateProjectOptions[], packageName: string): Set { + const result: Set = new Set(); + for (const { project, dependencyType } of projectUpdates) { + const versionSpecifier: string | undefined = this.#tryGetExistingDependency( + project, + packageName, + dependencyType + )?.version; + const catalogName: string | undefined = + versionSpecifier === undefined ? undefined : tryParsePnpmCatalogName(versionSpecifier); + if (catalogName !== undefined) { + result.add(catalogName); + } + } + + return result; + } + + /** + * Narrows to the first non-empty preferred set, then picks the only catalog or else the default catalog. + * Returns `undefined` if ambiguous. + */ + #trySelectCatalogName( + catalogNames: string[], + preferredCatalogNames: Iterable[] + ): string | undefined { + const selectFrom: (names: string[]) => string | undefined = (names: string[]) => { + if (names.length === 1) { + return names[0]; + } + + return names.includes(DEFAULT_PNPM_CATALOG_NAME) ? DEFAULT_PNPM_CATALOG_NAME : undefined; + }; + + if (catalogNames.length <= 1) { + return catalogNames[0]; + } + + for (const preferredNames of preferredCatalogNames) { + const preferredNameSet: ReadonlySet = new Set(preferredNames); + const preferredCatalogNamesToSelectFrom: string[] = catalogNames.filter((name) => + preferredNameSet.has(name) + ); + if (preferredCatalogNamesToSelectFrom.length > 0) { + return selectFrom(preferredCatalogNamesToSelectFrom); + } + } + + return selectFrom(catalogNames); + } + + /** + * An existing catalog reference is kept, since replacing it would silently detach the project from the + * catalog. If its entry differs, it is updated with "--make-consistent"; otherwise this throws. + */ + #getRushAddVersionSpecifier( + { project, dependencyType }: IUpdateProjectOptions, + packageName: string, + { version, versionSpecifier, isExplicitCatalogReference }: IRushAddSelectedVersion, + subspace: Subspace, + updateOtherPackages: boolean, + pendingCatalogUpdates: PendingPnpmCatalogUpdates + ): string { + if (isExplicitCatalogReference) { + return versionSpecifier; + } + + const existingVersionSpecifier: string | undefined = this.#tryGetExistingDependency( + project, + packageName, + dependencyType + )?.version; + if (existingVersionSpecifier === undefined) { + return versionSpecifier; + } + + const catalogReference: IPnpmCatalogReference | undefined = tryResolvePnpmCatalogReference( + packageName, + existingVersionSpecifier, + pendingCatalogUpdates.getCatalogs(subspace) + ); + if (!catalogReference) { + return versionSpecifier; + } + + if (catalogReference.resolvedVersion === undefined) { + this.#terminal.writeWarningLine( + `"${project.friendlyName}" references "${packageName}" through "${existingVersionSpecifier}", but ` + + `that PNPM catalog does not have an entry for it. Replacing the reference with "${versionSpecifier}".` + ); + return versionSpecifier; + } + + if (catalogReference.resolvedVersion === version) { + return existingVersionSpecifier; + } + + const pnpmConfigFilePath: string = pendingCatalogUpdates.getPnpmConfigFilePath(subspace); + if (updateOtherPackages) { + pendingCatalogUpdates.setVersion(subspace, catalogReference.catalogName, packageName, version); + this.#terminal.writeLine( + `Updating "${packageName}" in the PNPM catalog ` + + `"${formatPnpmCatalogSpecifier(catalogReference.catalogName)}" to "${version}" because ` + + `"${project.friendlyName}" references it.` + ); + return existingVersionSpecifier; + } + + throw new Error( + `"${project.friendlyName}" references "${packageName}" through the PNPM catalog reference ` + + `"${existingVersionSpecifier}", which is "${catalogReference.resolvedVersion}". Rush will not replace ` + + `the catalog reference with "${versionSpecifier}". Use the "${MAKE_CONSISTENT_FLAG_NAME}" flag to ` + + `update the catalog entry in ${pnpmConfigFilePath} to "${version}", or specify ` + + `"${packageName}@${existingVersionSpecifier}" to keep the catalog version.` + ); + } + + /** + * The dependency that {@link PackageJsonUpdater.updateProject} replaces for this dependency type. + */ + #tryGetExistingDependency( + project: VersionMismatchFinderEntity, + packageName: string, + dependencyType: DependencyType | undefined + ): PackageJsonDependency | undefined { + switch (dependencyType) { + case undefined: + return project.tryGetDevDependency(packageName) ?? project.tryGetDependency(packageName); + case DependencyType.Dev: + return project.tryGetDevDependency(packageName); + default: + return project.tryGetDependency(packageName); + } + } + + /** + * Warns about and ignores references to a catalog that has no entry for the package. + */ + #tryGetResolvedCatalogReference( + project: VersionMismatchFinderEntity, + packageName: string, + dependencyType: DependencyType, + catalogs: PnpmCatalogs | undefined + ): IPnpmCatalogReference | undefined { + const versionSpecifier: string | undefined = this.#tryGetExistingDependency( + project, + packageName, + dependencyType + )?.version; + if (versionSpecifier === undefined) { + return undefined; + } + + const catalogReference: IPnpmCatalogReference | undefined = tryResolvePnpmCatalogReference( + packageName, + versionSpecifier, + catalogs + ); + if (catalogReference?.resolvedVersion === undefined) { + if (catalogReference) { + this.#terminal.writeWarningLine( + `"${project.friendlyName}" references "${packageName}" through "${versionSpecifier}", but that ` + + 'PNPM catalog does not have an entry for it. Replacing the reference with a version.' ); - otherPackageUpdates = this.#getUpdates(mismatchFinder, Object.entries(dependenciesToAddOrUpdate)); } - this.updateProjects(otherPackageUpdates); + return undefined; + } + + return catalogReference; + } - allPackageUpdates.push(currentProjectUpdate, ...otherPackageUpdates); + /** + * The versions every project will use after the upgrade, including pending catalog changes. Like + * {@link IDependencyAnalysis.allVersionsByPackageName}, ignores peer and "workspace:" dependencies. + */ + #getVersionsAfterUpgrade( + packageName: string, + version: string, + dependencyType: DependencyType, + projectUpdatesByProject: ReadonlyMap, + pendingCatalogUpdates: PendingPnpmCatalogUpdates + ): Set { + const result: Set = new Set(); + for (const project of this.#rushConfiguration.projects) { + const { packageJsonEditor } = project; + for (const dependency of [ + packageJsonEditor.tryGetDependency(packageName), + packageJsonEditor.tryGetDevDependency(packageName) + ]) { + if (!dependency || dependency.dependencyType === DependencyType.Peer) { + continue; + } + + let dependencyVersion: string = dependency.version; + const catalogName: string | undefined = tryParsePnpmCatalogName(dependencyVersion); + if (catalogName !== undefined) { + const catalogVersion: string | undefined = tryGetPnpmCatalogVersion( + pendingCatalogUpdates.getCatalogs(project.subspace), + catalogName, + packageName + ); + if (catalogVersion === undefined) { + continue; + } + + dependencyVersion = catalogVersion; + } else if ( + projectUpdatesByProject.has(project) && + (dependency.dependencyType === DependencyType.Dev) === (dependencyType === DependencyType.Dev) + ) { + // The upgrade replaces this version + dependencyVersion = version; + } + + if (!dependencyVersion.startsWith('workspace:')) { + result.add(dependencyVersion); + } + } } - return allPackageUpdates; + return result; + } + + #throwMismatchedVersionsError( + packageName: string, + version: string, + existingVersions: Iterable + ): never { + const existingVersionList: string = Array.from(existingVersions).join(', '); + throw new Error( + `Adding '${packageName}@${version}' ` + + `causes mismatched dependencies. Use the "${MAKE_CONSISTENT_FLAG_NAME}" flag to update other packages to use ` + + `this version, or try specify one of the existing versions (${existingVersionList}).` + ); } #getUpdates( mismatchFinder: VersionMismatchFinder, - dependenciesToUpdate: Iterable<[string, string]> + versionByPackageName: ReadonlyMap, + subspace: Subspace, + pendingCatalogUpdates: PendingPnpmCatalogUpdates ): IUpdateProjectOptions[] { const result: IUpdateProjectOptions[] = []; const { mismatches } = mismatchFinder; - for (const [packageName, version] of dependenciesToUpdate) { + for (const [packageName, version] of versionByPackageName) { const projectsByVersion: ReadonlyMap> | undefined = mismatches.get(packageName); if (projectsByVersion) { @@ -472,12 +1056,18 @@ export class PackageJsonUpdater { ); if (catalogVersionSpecifier !== undefined) { if (existingVersion !== version) { - this.#reportCatalogReferenceNotUpdated( - consumer, - packageName, - catalogVersionSpecifier, - existingVersion - ); + const catalogName: string = tryParsePnpmCatalogName(catalogVersionSpecifier)!; + if ( + tryGetPnpmCatalogVersion( + pendingCatalogUpdates.getCatalogs(subspace), + catalogName, + packageName + ) === undefined + ) { + this.#reportUnresolvedCatalogReference(consumer, packageName, catalogVersionSpecifier); + } else { + pendingCatalogUpdates.setVersion(subspace, catalogName, packageName, version); + } } continue; @@ -502,19 +1092,20 @@ export class PackageJsonUpdater { packageName: string ): string | undefined { // Matches the dependency that updateProject() updates when it is not given a dependency type - const versionSpecifier: string | undefined = ( - project.tryGetDevDependency(packageName) ?? project.tryGetDependency(packageName) + const versionSpecifier: string | undefined = this.#tryGetExistingDependency( + project, + packageName, + undefined )?.version; return versionSpecifier !== undefined && tryParsePnpmCatalogName(versionSpecifier) !== undefined ? versionSpecifier : undefined; } - #reportCatalogReferenceNotUpdated( + #reportUnresolvedCatalogReference( project: VersionMismatchFinderEntity, packageName: string, - catalogVersionSpecifier: string, - catalogVersion: string + catalogVersionSpecifier: string ): void { const key: string = `${project.friendlyName}\0${packageName}`; if (this.#reportedCatalogReferences.has(key)) { @@ -523,9 +1114,9 @@ export class PackageJsonUpdater { this.#reportedCatalogReferences.add(key); this.#terminal.writeWarningLine( - `Not updating "${packageName}" in "${project.friendlyName}" because it uses the PNPM catalog ` + - `reference "${catalogVersionSpecifier}" (${catalogVersion}). Rush does not replace catalog ` + - `references when making versions consistent; update the catalog in pnpm-config.json instead.` + `Not updating "${packageName}" in "${project.friendlyName}" because its PNPM catalog reference ` + + `"${catalogVersionSpecifier}" does not resolve: the catalog does not have an entry for ` + + `"${packageName}". Add the entry to the catalog in pnpm-config.json.` ); } diff --git a/libraries/rush-lib/src/logic/pnpm/PendingPnpmCatalogUpdates.ts b/libraries/rush-lib/src/logic/pnpm/PendingPnpmCatalogUpdates.ts new file mode 100644 index 00000000000..64d6da3f133 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/PendingPnpmCatalogUpdates.ts @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Colorize, type ITerminal } from '@rushstack/terminal'; + +import type { RushConfiguration } from '../../api/RushConfiguration'; +import type { Subspace } from '../../api/Subspace'; +import { + getEffectivePnpmCatalogs, + getEffectivePnpmOptions, + mergePnpmCatalogs, + type PnpmCatalogs +} from './EffectivePnpmOptions'; +import type { PnpmOptionsConfiguration } from './PnpmOptionsConfiguration'; +import { formatPnpmCatalogSpecifier, tryGetPnpmCatalogVersion } from './PnpmCatalogReference'; + +export interface IPnpmCatalogEntryUpdate { + readonly catalogName: string; + readonly packageName: string; + readonly oldVersion: string | undefined; + readonly newVersion: string; +} + +interface IPendingPnpmConfigCatalogs { + readonly pnpmOptions: PnpmOptionsConfiguration; + readonly pnpmConfigFilePath: string; + /** + * A copy of the loaded catalogs, with the pending updates applied. + */ + readonly catalogs: PnpmCatalogs; + /** + * Keyed by `${catalogName}\0${packageName}` + */ + readonly updates: Map; +} + +/** + * Collects PNPM catalog changes so they can all be validated before any pnpm-config.json file is written. + * Changes are keyed by the effective pnpm-config.json file, since subspaces can share one. + */ +export class PendingPnpmCatalogUpdates { + readonly #rushConfiguration: RushConfiguration; + readonly #pendingByPnpmConfigFilePath: Map; + + public constructor(rushConfiguration: RushConfiguration) { + this.#rushConfiguration = rushConfiguration; + this.#pendingByPnpmConfigFilePath = new Map(); + } + + /** + * The subspace's effective catalogs, including pending updates. + */ + public getCatalogs(subspace: Subspace): PnpmCatalogs | undefined { + if (!this.#rushConfiguration.isPnpm) { + return undefined; + } + + const { pnpmConfigFilePath } = getEffectivePnpmOptions(subspace, this.#rushConfiguration); + const pending: IPendingPnpmConfigCatalogs | undefined = + this.#pendingByPnpmConfigFilePath.get(pnpmConfigFilePath); + return pending ? pending.catalogs : getEffectivePnpmCatalogs(subspace, this.#rushConfiguration); + } + + public getPnpmConfigFilePath(subspace: Subspace): string { + return getEffectivePnpmOptions(subspace, this.#rushConfiguration).pnpmConfigFilePath; + } + + /** + * Throws if a different version was already set for the same catalog entry. + */ + public setVersion(subspace: Subspace, catalogName: string, packageName: string, version: string): void { + const pending: IPendingPnpmConfigCatalogs = this.#getOrCreatePending(subspace); + const key: string = `${catalogName}\0${packageName}`; + const existingUpdate: IPnpmCatalogEntryUpdate | undefined = pending.updates.get(key); + if (existingUpdate) { + if (existingUpdate.newVersion !== version) { + throw new Error( + `Unable to set the version of "${packageName}" in the PNPM catalog ` + + `"${formatPnpmCatalogSpecifier(catalogName)}" of ${pending.pnpmConfigFilePath} to "${version}", ` + + `because it is also being set to "${existingUpdate.newVersion}".` + ); + } + + return; + } + + const oldVersion: string | undefined = tryGetPnpmCatalogVersion( + pending.catalogs, + catalogName, + packageName + ); + if (oldVersion === version) { + return; + } + + if (!Object.prototype.hasOwnProperty.call(pending.catalogs, catalogName)) { + pending.catalogs[catalogName] = {}; + } + + pending.catalogs[catalogName][packageName] = version; + pending.updates.set(key, { catalogName, packageName, oldVersion, newVersion: version }); + } + + /** + * @param subspacesToUpdate - the subspaces the caller will run "rush update" for. Other subspaces sharing + * a changed pnpm-config.json are reported, since their shrinkwrap files must be updated too. + */ + public async saveAsync(terminal: ITerminal, subspacesToUpdate: ReadonlySet): Promise { + for (const pending of this.#pendingByPnpmConfigFilePath.values()) { + const { pnpmOptions, pnpmConfigFilePath, catalogs, updates } = pending; + if (updates.size === 0) { + continue; + } + + await pnpmOptions.updateGlobalCatalogsAsync(catalogs); + + terminal.writeLine(Colorize.green('Updated PNPM catalogs in ') + pnpmConfigFilePath); + for (const { catalogName, packageName, oldVersion, newVersion } of updates.values()) { + terminal.writeLine( + ` ${formatPnpmCatalogSpecifier(catalogName)} ${packageName}: ` + + `${oldVersion ?? '(new entry)'} -> ${Colorize.cyan(newVersion)}` + ); + } + + const subspacesNotUpdated: Subspace[] = this.#getSubspacesUsingPnpmConfig(pnpmConfigFilePath).filter( + (subspace: Subspace) => !subspacesToUpdate.has(subspace) + ); + if (subspacesNotUpdated.length > 0) { + // Their shrinkwrap and repo-state.json files still reflect the old catalogs, so their next + // "rush install" would fail PNPM's lockfile catalog check. + const updateCommands: string[] = subspacesNotUpdated.map( + (subspace: Subspace) => ` rush update --subspace ${subspace.subspaceName}` + ); + terminal.writeWarningLine( + `The catalogs were updated in ${pnpmConfigFilePath}, which is also used by subspaces that this ` + + 'command does not update.\n' + + ' Run these commands to update their shrinkwrap files, then commit those changes to Git:\n' + + updateCommands.join('\n') + ); + } + } + } + + #getSubspacesUsingPnpmConfig(pnpmConfigFilePath: string): Subspace[] { + if (!this.#rushConfiguration.subspacesFeatureEnabled) { + return []; + } + + return this.#rushConfiguration.subspaces.filter( + (subspace: Subspace) => + getEffectivePnpmOptions(subspace, this.#rushConfiguration).pnpmConfigFilePath === pnpmConfigFilePath + ); + } + + #getOrCreatePending(subspace: Subspace): IPendingPnpmConfigCatalogs { + if (!this.#rushConfiguration.isPnpm) { + throw new Error('PNPM catalogs can only be updated when the package manager is PNPM.'); + } + + const { pnpmOptions, pnpmConfigFilePath } = getEffectivePnpmOptions(subspace, this.#rushConfiguration); + let pending: IPendingPnpmConfigCatalogs | undefined = + this.#pendingByPnpmConfigFilePath.get(pnpmConfigFilePath); + if (!pending) { + if (pnpmOptions.jsonFilename === undefined) { + throw new Error( + 'Unable to update the PNPM catalogs because the PNPM options are defined by the "pnpmOptions" ' + + `field in rush.json. Move them to ${pnpmConfigFilePath}.` + ); + } + + // Copy the catalogs, because the loaded object must not change until the file is written. + // mergePnpmCatalogs() returns its input only if there are no catalogs. + const { globalCatalogs } = pnpmOptions; + const catalogsCopy: PnpmCatalogs | undefined = mergePnpmCatalogs(globalCatalogs, undefined); + pending = { + pnpmOptions, + pnpmConfigFilePath, + catalogs: catalogsCopy && catalogsCopy !== globalCatalogs ? catalogsCopy : {}, + updates: new Map() + }; + this.#pendingByPnpmConfigFilePath.set(pnpmConfigFilePath, pending); + } + + return pending; + } +} diff --git a/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts b/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts index 4d84b14ceb1..3d8609b24fc 100644 --- a/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts +++ b/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts @@ -3,101 +3,524 @@ import * as path from 'node:path'; +import type { INpmCheckPackageSummary } from '@rushstack/npm-check-fork'; +import { TestUtilities } from '@rushstack/heft-config-file'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { DependencyType } from '../../api/PackageJsonEditor'; import { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import type { Subspace } from '../../api/Subspace'; +import { type IExecuteCommandAndCaptureOutputOptions, Utilities } from '../../utilities/Utilities'; +import { InstallHelpers } from '../installManager/InstallHelpers'; +import { InstallManagerFactory } from '../InstallManagerFactory'; +import type { BaseInstallManager } from '../base/BaseInstallManager'; import { PackageJsonUpdater } from '../PackageJsonUpdater'; import { type IPackageJsonUpdaterRushAddOptions, SemVerStyle } from '../PackageJsonUpdaterTypes'; +import { getEffectivePnpmCatalogs, type PnpmCatalogs } from '../pnpm/EffectivePnpmOptions'; +import { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; +import { PurgeManager } from '../PurgeManager'; import { VersionMismatchFinderProject } from '../versionMismatch/VersionMismatchFinderProject'; import { VersionMismatchFinderCommonVersions } from '../versionMismatch/VersionMismatchFinderCommonVersions'; +interface IAddOptions { + makeConsistent?: boolean; + dev?: boolean; + skipUpdate?: boolean; +} + describe(PackageJsonUpdater.name, () => { - describe('with PNPM catalog references', () => { - let rushConfiguration: RushConfiguration; - let terminalProvider: StringBufferTerminalProvider; - let packageJsonUpdater: PackageJsonUpdater; + let rushConfiguration: RushConfiguration; + let terminalProvider: StringBufferTerminalProvider; + let packageJsonUpdater: PackageJsonUpdater; + // The catalogs written to each pnpm-config.json file + let savedCatalogsByFilePath: Map; + // The latest version of each package in the mocked registry + let latestVersionByPackageName: Map; + let doInstallAsyncMock: jest.Mock; + + function loadRepo(repoFolderName: string): void { + rushConfiguration = RushConfiguration.loadFromConfigurationFile( + path.resolve(__dirname, repoFolderName, 'rush.json') + ); + terminalProvider = new StringBufferTerminalProvider(); + packageJsonUpdater = new PackageJsonUpdater( + new Terminal(terminalProvider), + rushConfiguration, + new RushGlobalFolder() + ); + } + + beforeEach(() => { + // Don't write the fixture files + jest.spyOn(VersionMismatchFinderProject.prototype, 'saveIfModifiedAsync').mockResolvedValue(false); + jest.spyOn(VersionMismatchFinderCommonVersions.prototype, 'saveIfModifiedAsync').mockResolvedValue(false); + + savedCatalogsByFilePath = new Map(); + jest + .spyOn(PnpmOptionsConfiguration.prototype, 'updateGlobalCatalogsAsync') + .mockImplementation(async function ( + this: PnpmOptionsConfiguration, + catalogs: PnpmCatalogs | undefined + ): Promise { + savedCatalogsByFilePath.set(this.jsonFilename!, TestUtilities.stripAnnotations(catalogs)); + }); + + // Don't query the registry + latestVersionByPackageName = new Map(); + jest.spyOn(InstallHelpers, 'ensureLocalPackageManagerAsync').mockResolvedValue(undefined); + ( + jest.spyOn(Utilities, 'executeCommandAndCaptureOutputAsync') as unknown as jest.SpyInstance< + Promise, + [IExecuteCommandAndCaptureOutputOptions] + > + ).mockImplementation(async ({ args }): Promise => { + // "view @latest version" or "view versions --json" + const packageName: string = args[1].replace(/@latest$/, ''); + const latestVersion: string | undefined = latestVersionByPackageName.get(packageName); + if (latestVersion === undefined) { + throw new Error(`Unexpected registry query: ${args.join(' ')}`); + } + + return args[2] === 'versions' ? JSON.stringify([latestVersion]) : `${latestVersion}\n`; + }); + + // Don't run "rush update" + doInstallAsyncMock = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(InstallManagerFactory, 'getInstallManagerAsync') + .mockResolvedValue({ doInstallAsync: doInstallAsyncMock } as unknown as BaseInstallManager); + jest.spyOn(PurgeManager.prototype, 'startDeleteAllAsync').mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function getProject(projectName: string): RushConfigurationProject { + return rushConfiguration.getProjectByName(projectName)!; + } + + function getVersionSpecifier(projectName: string, packageName: string): string | undefined { + return getProject(projectName).packageJsonEditor.tryGetDependency(packageName)?.version; + } + + function getDevVersionSpecifier(projectName: string, packageName: string): string | undefined { + return getProject(projectName).packageJsonEditor.tryGetDevDependency(packageName)?.version; + } + + function getCatalogs(subspace: Subspace = rushConfiguration.defaultSubspace): PnpmCatalogs { + return getEffectivePnpmCatalogs(subspace, rushConfiguration)!; + } + + function setPreferredVersion( + packageName: string, + version: string, + subspace: Subspace = rushConfiguration.defaultSubspace + ): void { + // Avoid querying the registry for the version + subspace.getCommonVersions().preferredVersions.set(packageName, version); + } + + async function addAsync( + projectNames: string[], + packagesToAdd: (string | [string, string])[], + { makeConsistent = false, dev = false, skipUpdate = true }: IAddOptions = {} + ): Promise { + const options: IPackageJsonUpdaterRushAddOptions = { + actionName: 'add', + projects: projectNames.map(getProject), + packagesToUpdate: packagesToAdd.map((packageToAdd) => + typeof packageToAdd === 'string' + ? { packageName: packageToAdd, version: undefined, rangeStyle: SemVerStyle.Tilde } + : { packageName: packageToAdd[0], version: packageToAdd[1], rangeStyle: SemVerStyle.Passthrough } + ), + updateOtherPackages: makeConsistent, + devDependency: dev, + peerDependency: false, + skipUpdate, + debugInstall: false, + variant: undefined + }; + await packageJsonUpdater.doRushUpdateAsync(options); + } + async function upgradeAsync( + projectNames: string[], + packagesToUpgrade: Pick< + INpmCheckPackageSummary, + 'moduleName' | 'latest' | 'packageJson' | 'devDependency' + >[], + { makeConsistent = false }: IAddOptions = {} + ): Promise { + await packageJsonUpdater.doRushUpgradeAsync({ + projects: projectNames.map(getProject), + packagesToAdd: packagesToUpgrade.map( + (summary) => + ({ + homepage: '', + installed: summary.packageJson, + notInstalled: false, + mismatch: false, + ...summary + }) as INpmCheckPackageSummary + ), + updateOtherPackages: makeConsistent, + skipUpdate: true, + debugInstall: false, + variant: undefined + }); + } + + describe('"rush add" with PNPM catalogs', () => { beforeEach(() => { - // Don't write the fixture files - jest.spyOn(VersionMismatchFinderProject.prototype, 'saveIfModifiedAsync').mockResolvedValue(false); - jest - .spyOn(VersionMismatchFinderCommonVersions.prototype, 'saveIfModifiedAsync') - .mockResolvedValue(false); - - rushConfiguration = RushConfiguration.loadFromConfigurationFile( - path.resolve(__dirname, 'repoWithCatalogs', 'rush.json') - ); - terminalProvider = new StringBufferTerminalProvider(); - packageJsonUpdater = new PackageJsonUpdater( - new Terminal(terminalProvider), - rushConfiguration, - new RushGlobalFolder() + // Catalogs: default { react: ^18.0.0, semver: ^7.5.4 }, tools { typescript: ~5.3.0, eslint: ^8.50.0 } + // a: react catalog:, semver catalog:; b: typescript catalog:tools (dev); c: none; d: semver catalog:; + // e: eslint catalog:tools (dev) + loadRepo('repoWithCatalogs'); + }); + + it('references the default catalog when no version is specified', async () => { + await addAsync(['c'], ['semver']); + + expect(getVersionSpecifier('c', 'semver')).toEqual('catalog:'); + expect(savedCatalogsByFilePath.size).toEqual(0); + expect(terminalProvider.getWarningOutput()).toEqual(''); + }); + + it('prefers the catalog that other projects reference the package through', async () => { + getCatalogs().default.typescript = '~5.4.0'; + + await addAsync(['c'], ['typescript'], { dev: true }); + + // "b" uses "catalog:tools" + expect(getDevVersionSpecifier('c', 'typescript')).toEqual('catalog:tools'); + }); + + it('prefers the default catalog when other projects do not reference the package', async () => { + getCatalogs().default.zod = '^3.0.0'; + getCatalogs().tools.zod = '^3.1.0'; + + await addAsync(['c'], ['zod']); + + expect(getVersionSpecifier('c', 'zod')).toEqual('catalog:'); + }); + + it('assigns a version and prints a note when the catalog is ambiguous', async () => { + const catalogs: PnpmCatalogs = getCatalogs(); + catalogs.x = { zod: '^3.0.0' }; + catalogs.y = { zod: '^3.1.0' }; + latestVersionByPackageName.set('zod', '3.23.8'); + + await addAsync(['c'], ['zod']); + + expect(getVersionSpecifier('c', 'zod')).toEqual('~3.23.8'); + expect(terminalProvider.getOutput()).toContain( + 'Note: Rush could not choose between the PNPM catalogs "catalog:x" (^3.0.0), "catalog:y" (^3.1.0) for "zod", ' + + 'so the version "~3.23.8" is assigned instead of a catalog reference. ' + + 'To reference a catalog, specify "zod@catalog:".' ); + expect(savedCatalogsByFilePath.size).toEqual(0); }); - afterEach(() => { - jest.restoreAllMocks(); - }); - - function getVersionSpecifier(projectName: string, packageName: string): string | undefined { - return rushConfiguration.getProjectByName(projectName)!.packageJsonEditor.tryGetDependency(packageName) - ?.version; - } - - async function addWithMakeConsistentAsync( - project: RushConfigurationProject, - packageName: string, - version: string - ): Promise { - // Avoid querying the registry for the version - rushConfiguration.defaultSubspace.getCommonVersions().preferredVersions.set(packageName, version); - - const options: IPackageJsonUpdaterRushAddOptions = { - actionName: 'add', - projects: [project], - packagesToUpdate: [{ packageName, version, rangeStyle: SemVerStyle.Passthrough }], - updateOtherPackages: true, - devDependency: false, - peerDependency: false, - skipUpdate: true, - debugInstall: false, - variant: undefined - }; - await packageJsonUpdater.doRushUpdateAsync(options); - } - - it('does not replace catalog references when making versions consistent', async () => { - // "a" and "d" use "semver": "catalog:", which resolves to ^7.5.4 - await addWithMakeConsistentAsync(rushConfiguration.getProjectByName('c')!, 'semver', '^7.6.0'); + it('references the catalog when the specified version matches the catalog entry', async () => { + await addAsync(['c'], [['semver', '^7.5.4']]); + + expect(getVersionSpecifier('c', 'semver')).toEqual('catalog:'); + expect(savedCatalogsByFilePath.size).toEqual(0); + }); + + it('references the catalog when the version that is found matches the catalog entry', async () => { + const catalogs: PnpmCatalogs = getCatalogs(); + catalogs.x = { zod: '^3.0.0' }; + catalogs.y = { zod: '~3.23.8' }; + latestVersionByPackageName.set('zod', '3.23.8'); + + await addAsync(['c'], ['zod']); + + expect(getVersionSpecifier('c', 'zod')).toEqual('catalog:y'); + }); + + it('assigns the specified version when it does not match the catalog entry', async () => { + setPreferredVersion('semver', '^7.6.0'); + + await addAsync(['c'], [['semver', '^7.6.0']]); expect(getVersionSpecifier('c', 'semver')).toEqual('^7.6.0'); expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); + expect(terminalProvider.getOutput()).toContain( + 'Note: "^7.6.0" for "semver" is different from the PNPM catalog entry "catalog:" (^7.5.4), ' + + 'so the version "^7.6.0" is assigned instead of a catalog reference. ' + + 'To reference a catalog, specify "semver@catalog:", ' + + 'or use the "--make-consistent" flag to update the catalog entry.' + ); + expect(savedCatalogsByFilePath.size).toEqual(0); + }); + + it('updates the catalog entry when making versions consistent', async () => { + setPreferredVersion('semver', '^7.6.0'); + + await addAsync(['c'], [['semver', '^7.6.0']], { makeConsistent: true }); + + expect(getVersionSpecifier('c', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); + expect(Object.fromEntries(savedCatalogsByFilePath)).toEqual({ + [rushConfiguration.defaultSubspace.getPnpmConfigFilePath()]: { + default: { react: '^18.0.0', semver: '^7.6.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + } + }); + expect(terminalProvider.getOutput()).toContain('catalog: semver: ^7.5.4 -> ^7.6.0'); + expect(terminalProvider.getWarningOutput()).toEqual(''); + }); + + it('updates projects that use a version when making versions consistent with a catalog', async () => { + getProject('b').packageJsonEditor.addOrUpdateDependency('semver', '~7.0.0', DependencyType.Regular); + + await addAsync(['c'], [['semver', '^7.5.4']], { makeConsistent: true }); + + expect(getVersionSpecifier('b', 'semver')).toEqual('^7.5.4'); + expect(getVersionSpecifier('c', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); + expect(savedCatalogsByFilePath.size).toEqual(0); + expect(terminalProvider.getWarningOutput()).toEqual(''); + }); + + it('updates catalog entries that other projects reference when making versions consistent', async () => { + // "e" uses "eslint": "catalog:tools" + getProject('c').packageJsonEditor.addOrUpdateDependency('eslint', '^8.0.0', DependencyType.Dev); + getCatalogs().default.eslint = '^8.0.0'; + + await addAsync(['c'], [['eslint', '^8.0.0']], { dev: true, makeConsistent: true }); + + expect(getDevVersionSpecifier('c', 'eslint')).toEqual('catalog:'); + expect(getDevVersionSpecifier('e', 'eslint')).toEqual('catalog:tools'); + expect( + TestUtilities.stripAnnotations( + savedCatalogsByFilePath.get(rushConfiguration.defaultSubspace.getPnpmConfigFilePath()) + ) + ).toMatchObject({ + default: { eslint: '^8.0.0' }, + tools: { eslint: '^8.0.0' } + }); + }); + + it.each([ + ['semver', 'catalog:default', 'catalog:', false], + ['typescript', 'catalog:tools', 'catalog:tools', true] + ])( + 'writes the explicitly specified catalog reference %s@%s', + async (packageName: string, versionSpecifier: string, expected: string, dev: boolean) => { + await addAsync(['c'], [[packageName, versionSpecifier]], { dev }); + + expect( + dev ? getDevVersionSpecifier('c', packageName) : getVersionSpecifier('c', packageName) + ).toEqual(expected); + expect(savedCatalogsByFilePath.size).toEqual(0); + } + ); + + it('reports an explicitly specified catalog that does not exist', async () => { + await expect(addAsync(['c'], [['semver', 'catalog:missing']])).rejects.toThrow( + 'The PNPM catalog "missing" that "semver@catalog:missing" references is not defined' + ); + expect(getVersionSpecifier('c', 'semver')).toBeUndefined(); + }); + + it('reports an explicitly specified catalog that does not have the package', async () => { + await expect(addAsync(['c'], [['semver', 'catalog:tools']])).rejects.toThrow( + 'does not have an entry for "semver"' + ); + expect(getVersionSpecifier('c', 'semver')).toBeUndefined(); + }); + + it('keeps the catalog reference of the project when the version matches', async () => { + getProject('a').packageJsonEditor.addOrUpdateDependency( + 'semver', + 'catalog:default', + DependencyType.Regular + ); + + await addAsync(['a'], [['semver', '^7.5.4']]); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:default'); + + await addAsync(['a'], ['semver']); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:default'); + }); + + it('prefers the catalog that the project already references', async () => { + getCatalogs().tools.semver = '^7.5.4'; + getProject('a').packageJsonEditor.addOrUpdateDependency( + 'semver', + 'catalog:tools', + DependencyType.Regular + ); + + await addAsync(['a'], ['semver']); + + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:tools'); + }); + + it('does not replace the catalog reference of the project with a different version', async () => { + setPreferredVersion('semver', '^7.6.0'); + + await expect(addAsync(['a'], [['semver', '^7.6.0']])).rejects.toThrow( + '"a" references "semver" through the PNPM catalog reference "catalog:", which is "^7.5.4". ' + + 'Rush will not replace the catalog reference with "^7.6.0".' + ); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(savedCatalogsByFilePath.size).toEqual(0); + }); + + it('updates the catalog entry that the project references when making versions consistent', async () => { + setPreferredVersion('eslint', '^9.0.0'); + + await addAsync(['e'], [['eslint', '^9.0.0']], { dev: true, makeConsistent: true }); + + expect(getDevVersionSpecifier('e', 'eslint')).toEqual('catalog:tools'); + expect(savedCatalogsByFilePath.get(rushConfiguration.defaultSubspace.getPnpmConfigFilePath())).toEqual({ + default: { react: '^18.0.0', semver: '^7.5.4' }, + tools: { typescript: '~5.3.0', eslint: '^9.0.0' } + }); + }); + }); + + describe('"rush add" with subspaces that share a pnpm-config.json file', () => { + beforeEach(() => { + // common/config/rush/pnpm-config.json: default { react: ^18.0.0, foo: ~2.0.0 }, tools { typescript: ~5.3.0 } + // own-config-subspace: default { react: ^17.0.0 } + // default subspace: a (react catalog:), b (typescript catalog:tools) + // other-subspace: c (foo catalog:) + // own-config-subspace: e (react catalog:) + loadRepo('repoWithSubspacesCommonCatalogs'); + }); + + it('reports the other subspaces that use the updated catalogs', async () => { + setPreferredVersion('react', '^18.3.0'); + + await addAsync(['b'], [['react', '^18.3.0']], { makeConsistent: true, skipUpdate: false }); + + expect(getVersionSpecifier('b', 'react')).toEqual('catalog:'); + expect(getVersionSpecifier('a', 'react')).toEqual('catalog:'); + const commonPnpmConfigFilePath: string = `${rushConfiguration.commonRushConfigFolder}/pnpm-config.json`; + expect(Object.fromEntries(savedCatalogsByFilePath)).toEqual({ + [commonPnpmConfigFilePath]: { + default: { react: '^18.3.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + } + }); + expect(doInstallAsyncMock).toHaveBeenCalledTimes(1); const warnings: string = terminalProvider.getWarningOutput(); - expect(warnings).toContain( - 'Not updating "semver" in "a" because it uses the PNPM catalog reference "catalog:" (^7.5.4).' + expect(warnings).toContain('rush update --subspace other-subspace'); + expect(warnings).not.toContain('rush update --subspace default'); + expect(warnings).not.toContain('own-config-subspace'); + }); + + it('updates the catalogs of a subspace with its own pnpm-config.json file', async () => { + const ownConfigSubspace: Subspace = rushConfiguration.getSubspace('own-config-subspace'); + setPreferredVersion('react', '^17.0.2', ownConfigSubspace); + + await addAsync(['e'], [['react', '^17.0.2']], { makeConsistent: true, skipUpdate: false }); + + expect(getVersionSpecifier('e', 'react')).toEqual('catalog:'); + expect(Object.fromEntries(savedCatalogsByFilePath)).toEqual({ + [ownConfigSubspace.getPnpmConfigFilePath()]: { default: { react: '^17.0.2' } } + }); + expect(terminalProvider.getWarningOutput()).toEqual(''); + }); + + it('reports the subspaces to update when "rush update" is skipped', async () => { + setPreferredVersion('react', '^18.3.0'); + + await addAsync(['b'], [['react', '^18.3.0']], { makeConsistent: true }); + + expect(doInstallAsyncMock).not.toHaveBeenCalled(); + const warnings: string = terminalProvider.getWarningOutput(); + expect(warnings).toContain('rush update --subspace default'); + expect(warnings).toContain('rush update --subspace other-subspace'); + }); + + it('enforces consistent versions when the catalog entry is different', async () => { + setPreferredVersion('react', '^18.3.0'); + + await expect(addAsync(['b'], [['react', '^18.3.0']])).rejects.toThrow( + "Adding 'react@^18.3.0' causes mismatched dependencies." ); - expect(warnings).toContain( - 'Not updating "semver" in "d" because it uses the PNPM catalog reference "catalog:" (^7.5.4).' + expect(savedCatalogsByFilePath.size).toEqual(0); + }); + }); + + describe('"rush upgrade-interactive" with PNPM catalogs', () => { + it('updates the catalog entry instead of the catalog reference', async () => { + loadRepo('repoWithCatalogs'); + latestVersionByPackageName.set('semver', '7.6.3'); + + await upgradeAsync( + ['a'], + [{ moduleName: 'semver', latest: '7.6.3', packageJson: '^7.5.4', devDependency: false }] + ); + + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); + expect(savedCatalogsByFilePath.get(rushConfiguration.defaultSubspace.getPnpmConfigFilePath())).toEqual({ + default: { react: '^18.0.0', semver: '^7.6.3' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + }); + expect(terminalProvider.getOutput()).toContain( + '"a" references "semver" through the PNPM catalog "catalog:", so the catalog entry will be updated' ); }); - it('updates other projects without reporting catalog references that are already consistent', async () => { - rushConfiguration - .getProjectByName('b')! - .packageJsonEditor.addOrUpdateDependency('semver', '~7.0.0', DependencyType.Regular); + it('updates the version of projects that do not reference a catalog', async () => { + loadRepo('repoWithCatalogs'); + latestVersionByPackageName.set('semver', '7.6.3'); + getProject('b').packageJsonEditor.addOrUpdateDependency('semver', '~7.0.0', DependencyType.Regular); - await addWithMakeConsistentAsync(rushConfiguration.getProjectByName('c')!, 'semver', '^7.5.4'); + await upgradeAsync( + ['a', 'b'], + [{ moduleName: 'semver', latest: '7.6.3', packageJson: '^7.5.4', devDependency: false }] + ); + + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('b', 'semver')).toEqual('^7.6.3'); + }); + + it('updates the other projects when making versions consistent', async () => { + loadRepo('repoWithCatalogs'); + latestVersionByPackageName.set('semver', '7.6.3'); + getProject('c').packageJsonEditor.addOrUpdateDependency('semver', '~7.0.0', DependencyType.Regular); + + await upgradeAsync( + ['a'], + [{ moduleName: 'semver', latest: '7.6.3', packageJson: '^7.5.4', devDependency: false }], + { makeConsistent: true } + ); - expect(getVersionSpecifier('b', 'semver')).toEqual('^7.5.4'); - expect(getVersionSpecifier('c', 'semver')).toEqual('^7.5.4'); expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('c', 'semver')).toEqual('^7.6.3'); expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); - expect(terminalProvider.getWarningOutput()).toEqual(''); + expect( + savedCatalogsByFilePath.get(rushConfiguration.defaultSubspace.getPnpmConfigFilePath())?.default + ).toEqual({ react: '^18.0.0', semver: '^7.6.3' }); + }); + + it('enforces consistent versions after updating the catalog entry', async () => { + loadRepo('repoWithSubspacesCommonCatalogs'); + latestVersionByPackageName.set('typescript', '5.4.5'); + getProject('a').packageJsonEditor.addOrUpdateDependency('typescript', '~5.3.0', DependencyType.Dev); + + await expect( + upgradeAsync( + ['b'], + [{ moduleName: 'typescript', latest: '5.4.5', packageJson: '~5.3.0', devDependency: true }] + ) + ).rejects.toThrow("Adding 'typescript@~5.4.5' causes mismatched dependencies."); + expect(savedCatalogsByFilePath.size).toEqual(0); }); }); }); diff --git a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts index 64ee375aa01..6c627ea908d 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts @@ -35,7 +35,13 @@ export interface IVersionMismatchFinderRushCheckOptions extends IVersionMismatch export interface IVersionMismatchFinderEnsureConsistentVersionsOptions extends IVersionMismatchFinderOptions {} -export interface IVersionMismatchFinderGetMismatchesOptions extends IVersionMismatchFinderOptions {} +export interface IVersionMismatchFinderGetMismatchesOptions extends IVersionMismatchFinderOptions { + /** + * Catalogs for resolving `catalog:` references. Defaults to the subspace's effective catalogs; pass this to + * include unsaved changes. + */ + pnpmCatalogs?: PnpmCatalogs; +} export interface IMismatchDependency { dependencyName: string; @@ -136,7 +142,7 @@ export class VersionMismatchFinder { */ public static getMismatches( rushConfiguration: RushConfiguration, - options?: IVersionMismatchFinderOptions + options?: IVersionMismatchFinderGetMismatchesOptions ): VersionMismatchFinder { const { subspace = rushConfiguration.defaultSubspace, variant } = options ?? {}; const commonVersions: CommonVersionsConfiguration = subspace.getCommonVersions(variant); @@ -147,7 +153,8 @@ export class VersionMismatchFinder { // Make sure this one is first so it doesn't get truncated when a long list is printed projects.push(new VersionMismatchFinderCommonVersions(commonVersions)); - const pnpmCatalogs: PnpmCatalogs | undefined = getEffectivePnpmCatalogs(subspace, rushConfiguration); + const pnpmCatalogs: PnpmCatalogs | undefined = + options?.pnpmCatalogs ?? getEffectivePnpmCatalogs(subspace, rushConfiguration); // If subspace is specified, only go through projects in that subspace for (const project of subspace.getProjects()) {