From aea083e271069e3ecbaffd8cdda150821b56eff2 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:44:59 -0700 Subject: [PATCH] Make rush add and upgrade-interactive catalog-aware "rush add" writes PNPM catalog references (catalog: / catalog:) when a catalog of the project's subspace has a matching entry, accepts explicit catalog references, and with --make-consistent updates the catalog entry instead of replacing references with versions. A project's existing catalog reference is never silently replaced with a version. "rush upgrade-interactive" lists dependencies referenced through a catalog and upgrades them by bumping the catalog entry. When the updated pnpm-config.json is shared with other subspaces, the needed "rush update --subspace" commands are printed. npm-check-fork now honors a provided cwdPackageJson. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...talog-aware-rush-add_2026-10-14-20-37.json | 11 + ...talog-aware-rush-add_2026-10-14-20-37.json | 11 + libraries/npm-check-fork/src/NpmCheckState.ts | 9 +- .../src/interfaces/INpmCheck.ts | 8 + .../src/tests/NpmCheckState.test.ts | 18 +- .../RushCommandLine.test.ts.snap | 2 +- .../rush-lib/src/cli/actions/AddAction.ts | 20 +- .../src/cli/actions/test/AddAction.test.ts | 60 ++ .../CommandLineHelp.test.ts.snap | 24 +- .../rush-lib/src/logic/InteractiveUpgrader.ts | 57 +- .../rush-lib/src/logic/PackageJsonUpdater.ts | 922 +++++++++++++++--- .../logic/pnpm/PendingPnpmCatalogUpdates.ts | 214 ++++ .../src/logic/test/PackageJsonUpdater.test.ts | 600 ++++++++++-- .../versionMismatch/VersionMismatchFinder.ts | 13 +- 14 files changed, 1743 insertions(+), 226 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json create mode 100644 common/changes/@rushstack/npm-check-fork/bmiddha-catalog-aware-rush-add_2026-10-14-20-37.json create mode 100644 libraries/rush-lib/src/logic/pnpm/PendingPnpmCatalogUpdates.ts 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..ccf48f8a0f7 --- /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. \"rush add\" now writes a catalog reference (such as \"catalog:\" or \"catalog:tools\") instead of a version when a catalog of the project's subspace has an entry for the package that matches the requested version (or when no version is specified), and accepts explicit catalog references such as \"rush add -p example@catalog:tools\". With \"--make-consistent\", the catalog entry in pnpm-config.json is updated instead of replacing catalog references with versions. A project's existing catalog reference is never replaced with a version. \"rush upgrade-interactive\" now lists dependencies that are referenced through a catalog, and upgrades them by updating the catalog entry. When the updated pnpm-config.json is shared with other subspaces, Rush prints the \"rush update --subspace\" commands that are needed to update their shrinkwrap files.", + "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..f2c9378ba17 --- /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": "Use the \"cwdPackageJson\" option when it is provided, instead of always reading package.json from the \"cwd\" folder. The default options are no longer modified by 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..ddf3454d994 100644 --- a/libraries/npm-check-fork/src/interfaces/INpmCheck.ts +++ b/libraries/npm-check-fork/src/interfaces/INpmCheck.ts @@ -13,6 +13,14 @@ export interface INpmCheckPackageJson { export interface INpmCheckState { cwd: string; + /** + * The package.json contents of the project to check. If this is not provided, it is read from + * the package.json file in `cwd`. + * + * @remarks + * Provide this to check dependency version specifiers that differ from the package.json file, for example + * after resolving references such as PNPM's `catalog:` protocol to the versions that they refer to. + */ 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..aeff7f4ddf7 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,20 @@ 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.cwd).toBe(process.cwd()); + expect(state.cwdPackageJson).toBe(cwdPackageJson); + + // The provided package.json must not leak into later calls + 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..2502ef4b835 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,12 @@ 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.` + + ' When using PNPM catalogs, a catalog reference such as "catalog:" is written instead of a version if' + + ' the catalog has an entry for the package that matches the requested version (or if no version is' + + ` specified). With the "${MAKE_CONSISTENT_FLAG_NAME}" flag, the catalog entry is updated to the requested` + + ' version. A catalog can also be referenced explicitly, for example "example@catalog:" or' + + ' "example@catalog:tools".' ].join('\n'); super({ actionName: ADD_ACTION_NAME, @@ -46,7 +52,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 +122,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..0a854a40092 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,12 @@ 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. When using PNPM catalogs, a catalog reference +such as \\"catalog:\\" is written instead of a version if the catalog has an +entry for the package that matches the requested version (or if no version is +specified). With the \\"--make-consistent\\" flag, the catalog entry is updated +to the requested version. A catalog can also be referenced explicitly, for +example \\"example@catalog:\\" or \\"example@catalog:tools\\". Optional arguments: -h, --help Show this help message and exit. @@ -107,14 +112,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..a9aa6bd1e5d 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,59 @@ export class InteractiveUpgrader { const { projectFolder } = rushProject; const currentState: INpmCheckState = await NpmCheck({ - cwd: projectFolder + cwd: projectFolder, + cwdPackageJson: this.#tryGetPackageJsonWithResolvedCatalogReferences(rushProject) }); return currentState.packages ?? []; } + + /** + * NpmCheck ignores dependencies whose version specifier is not a SemVer range, such as PNPM catalog + * references (`catalog:`). If the project has catalog references, returns a copy of its package.json + * with each reference replaced by the version from the catalog, so that those dependencies can be upgraded. + * The upgrade updates the catalog entry instead of the reference (see PackageJsonUpdater.doRushUpgradeAsync). + */ + #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 76b0471ddb3..62426f2104f 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,37 @@ export interface IUpdateProjectOptions extends IBaseUpdateProjectOptions { */ export interface IRemoveProjectOptions extends IBaseUpdateProjectOptions {} +/** + * The version of a package that "rush add" selected. + */ +interface IRushAddSelectedVersion { + /** + * The version specifier that the package resolves to. This is never a PNPM catalog reference. + */ + version: string; + /** + * The version specifier to write to package.json, which is either `version` or a PNPM catalog reference. + */ + versionSpecifier: string; + /** + * The name of the PNPM catalog that `versionSpecifier` references, if any. + */ + catalogName: string | undefined; + /** + * True if the catalog reference was specified explicitly, such as `rush add --package example@catalog:`. + */ + isExplicitCatalogReference: boolean; +} + +/** + * The changes that "rush upgrade-interactive" makes to the package.json file of a project. + */ +interface IUpgradeProjectUpdates { + project: VersionMismatchFinderEntity; + dependenciesToUpdate: Record; + devDependenciesToUpdate: Record; +} + /** * A helper class for managing the dependencies of various package.json files. * @internal @@ -123,6 +164,10 @@ export class PackageJsonUpdater { /** * Upgrade dependencies to a particular project, or across specified projects. This is the core business logic for * "rush upgrade-interactive". + * + * @remarks + * If a project references a dependency through a PNPM catalog (such as `catalog:`), the catalog entry is + * updated instead, and the project keeps its catalog reference. This matches `pnpm update`. */ public async doRushUpgradeAsync(options: IPackageJsonUpdaterRushUpgradeOptions): Promise { const { projects, packagesToAdd, updateOtherPackages, skipUpdate, debugInstall, variant } = options; @@ -139,12 +184,47 @@ 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: {} + }); + } + + // The new version of each package. This is never a catalog reference. + 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; + + // The projects that reference the package through a PNPM catalog get the catalog entry updated instead + 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); @@ -161,72 +241,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); + } } } @@ -241,24 +356,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 { @@ -277,17 +395,24 @@ 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); + } + } + + /** + * Returns the subspaces that "rush update" runs for after the package.json files of the projects are changed. + */ + #getSubspacesToUpdate(projects: Iterable): ReadonlySet { + if (this.#rushConfiguration.subspacesFeatureEnabled) { + return this.#rushConfiguration.getSubspacesForProjects(projects); + } else { + return new Set([this.#rushConfiguration.defaultSubspace]); } } @@ -336,7 +461,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( @@ -351,7 +479,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; @@ -360,7 +490,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; @@ -370,39 +501,63 @@ 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); + // Each project gets its own record, because a project that references a package through a PNPM catalog + // keeps its catalog reference. + const projectUpdates: IUpdateProjectOptions[] = subspaceProjects.map((project) => ({ + project: new VersionMismatchFinderProject(project), + dependenciesToAddOrUpdateOrRemove: {}, + dependencyType + })); - const version: string = await this.#getNormalizedVersionSpecAsync( + // The version that each package resolves to. This is never a catalog reference. + 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 && @@ -412,76 +567,560 @@ 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]; + } + + /** + * Selects the version of a package that "rush add" adds to the projects of a subspace. + * + * @remarks + * If the PNPM catalogs of the subspace have an entry for the package, a catalog reference is selected when + * the catalog entry matches the requested version (or when no version was requested), like PNPM's + * `catalogMode: prefer` setting. If the "--make-consistent" flag was specified, the catalog entry is + * updated to the requested version. + */ + 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, + catalogName: explicitCatalogName, + 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 literalVersion: IRushAddSelectedVersion = { + version, + versionSpecifier: version, + catalogName: undefined, + 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 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 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, catalogName, isExplicitCatalogReference: false }; + } - let otherPackageUpdates: IUpdateProjectOptions[] = []; + /** + * Returns the version from the catalog entry that an explicitly specified catalog reference refers to, + * such as `rush add --package example@catalog:tools`. + */ + #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}".` + ); + } + + /** + * Returns the names of the catalogs that have an entry for the package. Rush only references a catalog + * implicitly for packages that are not local projects linked with the "workspace:" protocol. + */ + #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 + ); + } + + /** + * Returns the names of the catalogs that the projects reference the package through. + */ + #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; + } + + /** + * Chooses one of the catalogs. Catalogs from the first of the `preferredCatalogNames` sets that includes any + * of them are considered first. Among those, a single catalog or else the default catalog is chosen. + * Returns `undefined` if the choice is 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); + } + + /** + * Returns the version specifier that "rush add" writes for the package in a project. + * + * @remarks + * If the project already references the package through a PNPM catalog, the reference is kept, because + * replacing it would silently detach the project from the catalog. If the catalog entry has a different + * version, it is updated when the "--make-consistent" flag was specified; 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.` + ); + } + + /** + * Returns the existing dependency that {@link PackageJsonUpdater.updateProject} replaces when it adds the + * package with the specified 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); + } + } + + /** + * If the project references the package through a PNPM catalog that has an entry for it, returns the + * catalog reference. A reference to a catalog without an entry for the package is reported. + */ + #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; + } + + /** + * Returns the version specifiers that the projects in the repo will use for the package after an upgrade, + * resolving PNPM catalog references with the pending catalog changes. Peer dependencies and "workspace:" + * references are ignored, like {@link IDependencyAnalysis.allVersionsByPackageName}. + */ + #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; + } - allPackageUpdates.push(currentProjectUpdate, ...otherPackageUpdates); + 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}).` + ); + } + + /** + * Returns the changes that make the other projects (and common-versions.json) consistent with the new versions. + * + * @remarks + * A project that references a package through a PNPM catalog keeps its catalog reference; the catalog + * entry is updated instead. + */ #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) { for (const [existingVersion, consumers] of projectsByVersion) { for (const consumer of consumers) { // Never replace a PNPM catalog reference with a literal version: that would silently detach the - // project from the catalog. The catalog entry must be updated instead. + // project from the catalog. Update the catalog entry instead. const catalogVersionSpecifier: string | undefined = this.#tryGetCatalogVersionSpecifierToUpdate( consumer, packageName ); 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; @@ -510,19 +1149,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)) { @@ -531,9 +1171,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..b845a68e19f --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/PendingPnpmCatalogUpdates.ts @@ -0,0 +1,214 @@ +// 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'; + +/** + * A change to the version of a PNPM catalog entry. + */ +export interface IPnpmCatalogEntryUpdate { + readonly catalogName: string; + readonly packageName: string; + readonly oldVersion: string | undefined; + readonly newVersion: string; +} + +interface IPendingPnpmConfigCatalogs { + readonly pnpmOptions: PnpmOptionsConfiguration; + readonly pnpmConfigFilePath: string; + /** + * The catalogs after the pending updates are applied. This is a plain copy of the loaded catalogs. + */ + readonly catalogs: PnpmCatalogs; + /** + * Keyed by `${catalogName}\0${packageName}` + */ + readonly updates: Map; +} + +/** + * Collects changes to the PNPM catalogs in pnpm-config.json files, so that every change can be planned + * (and validated) before any file is written. + * + * @remarks + * Changes are tracked per effective pnpm-config.json file (see {@link getEffectivePnpmOptions}), because several + * subspaces can share the same file. Catalog entries are only added or updated, never removed. + */ +export class PendingPnpmCatalogUpdates { + readonly #rushConfiguration: RushConfiguration; + readonly #pendingByPnpmConfigFilePath: Map; + + public constructor(rushConfiguration: RushConfiguration) { + this.#rushConfiguration = rushConfiguration; + this.#pendingByPnpmConfigFilePath = new Map(); + } + + /** + * True if any catalog entry will be changed by {@link PendingPnpmCatalogUpdates.saveAsync}. + */ + public get hasUpdates(): boolean { + for (const { updates } of this.#pendingByPnpmConfigFilePath.values()) { + if (updates.size > 0) { + return true; + } + } + + return false; + } + + /** + * Returns the catalogs that are in effect for the subspace, including the 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); + } + + /** + * Returns the pnpm-config.json file that stores the catalogs for the subspace. + */ + public getPnpmConfigFilePath(subspace: Subspace): string { + return getEffectivePnpmOptions(subspace, this.#rushConfiguration).pnpmConfigFilePath; + } + + /** + * Schedules setting the version of a catalog entry in the catalogs that are in effect for the subspace. + * + * @remarks + * Throws if a different version was already scheduled 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 }); + } + + /** + * Writes the pending catalog changes to the pnpm-config.json files. + * + * @param subspacesToUpdate - the subspaces that the caller will run "rush update" for. Other subspaces + * that use a changed pnpm-config.json are reported, because 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) { + // The shrinkwrap files and repo-state.json files of these subspaces still reflect the old catalogs. + // Their next "rush install" would fail the PNPM lockfile catalog check, so they must be updated too. + 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..3c692d3f18c 100644 --- a/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts +++ b/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts @@ -3,101 +3,573 @@ 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('references the only catalog that has the package', async () => { + await addAsync(['c'], ['typescript'], { dev: true }); + + expect(getDevVersionSpecifier('c', 'typescript')).toEqual('catalog:tools'); + expect(getVersionSpecifier('c', 'typescript')).toBeUndefined(); + }); + + 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); + }); + + 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'); }); - 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('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:', 'catalog:', false], + ['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('replaces a version with an explicitly specified catalog reference', async () => { + getProject('c').packageJsonEditor.addOrUpdateDependency('semver', '~7.0.0', DependencyType.Regular); + + await addAsync(['c'], [['semver', 'catalog:']]); + + expect(getVersionSpecifier('c', 'semver')).toEqual('catalog:'); + }); + + 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('reports an explicitly specified catalog when the package manager is not PNPM', async () => { + Object.defineProperty(rushConfiguration, 'isPnpm', { value: false }); + + await expect(addAsync(['c'], [['semver', 'catalog:']])).rejects.toThrow( + 'catalogs are only supported when the package manager is PNPM' + ); + }); + + 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:), d (lodash ^4.17.0) + // 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 catalog entry of a dev dependency', async () => { + loadRepo('repoWithCatalogs'); + latestVersionByPackageName.set('typescript', '5.4.5'); - await addWithMakeConsistentAsync(rushConfiguration.getProjectByName('c')!, 'semver', '^7.5.4'); + await upgradeAsync( + ['b'], + [{ moduleName: 'typescript', latest: '5.4.5', packageJson: '~5.3.0', devDependency: true }] + ); + + expect(getDevVersionSpecifier('b', 'typescript')).toEqual('catalog:tools'); + expect( + savedCatalogsByFilePath.get(rushConfiguration.defaultSubspace.getPnpmConfigFilePath())?.tools + ).toEqual({ typescript: '~5.4.5', eslint: '^8.50.0' }); + }); + + 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 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'); + + // "b" is the only project that uses typescript + await upgradeAsync( + ['b'], + [{ moduleName: 'typescript', latest: '5.4.5', packageJson: '~5.3.0', devDependency: true }] + ); + expect(getDevVersionSpecifier('b', 'typescript')).toEqual('catalog:tools'); + + loadRepo('repoWithSubspacesCommonCatalogs'); + getProject('a').packageJsonEditor.addOrUpdateDependency('typescript', '~5.3.0', DependencyType.Dev); + savedCatalogsByFilePath.clear(); + + 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 77662b41ff9..6770bb2a6c7 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 { + /** + * The PNPM catalogs to resolve catalog references with. Defaults to the catalogs that are in effect + * for the subspace. Use this to account for catalog changes that have not been saved yet. + */ + pnpmCatalogs?: PnpmCatalogs; +} export interface IMismatchDependency { dependencyName: string; @@ -146,7 +152,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); @@ -159,7 +165,8 @@ export class VersionMismatchFinder { // Every project of the subspace belongs to the subspace, so they all resolve catalog references // with the subspace's catalogs. - 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()) {