From cb746ea92e969cb1d79fa3ecee5dd8e11f1525a3 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:05:01 -0700 Subject: [PATCH] Make version consistency checks catalog-aware Compare PNPM catalog references by the version that they resolve to in the catalogs of the project's subspace, so "catalog:" no longer mismatches the same literal version or preferredVersions in "rush check", ensureConsistentVersions, and "rush version". Unresolvable references are reported as their own version. "rush check" annotates catalog versions and "rush check --json" adds an optional "catalogReferences" field. "--make-consistent" no longer rewrites other projects' catalog references to literal versions; it warns instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...aware-version-checks_2026-10-14-00-00.json | 11 + .../api/test/VersionMismatchFinder.test.ts | 278 +++++++++++++++++- .../rush-lib/src/logic/PackageJsonUpdater.ts | 75 ++++- .../src/logic/ProjectChangeAnalyzer.ts | 8 +- .../src/logic/pnpm/PnpmCatalogReference.ts | 10 + .../pnpm/test/PnpmCatalogReference.test.ts | 15 + .../src/logic/test/PackageJsonUpdater.test.ts | 103 +++++++ .../logic/test/ProjectChangeAnalyzer.test.ts | 29 ++ .../versionMismatch/VersionMismatchFinder.ts | 142 ++++++++- .../VersionMismatchFinderEntity.ts | 10 +- .../VersionMismatchFinderProject.ts | 10 +- 11 files changed, 665 insertions(+), 26 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-catalog-aware-version-checks_2026-10-14-00-00.json create mode 100644 libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts diff --git a/common/changes/@microsoft/rush/bmiddha-catalog-aware-version-checks_2026-10-14-00-00.json b/common/changes/@microsoft/rush/bmiddha-catalog-aware-version-checks_2026-10-14-00-00.json new file mode 100644 index 00000000000..aa380bf66a2 --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-catalog-aware-version-checks_2026-10-14-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Make version consistency checks (\"rush check\", \"ensureConsistentVersions\", and \"--make-consistent\") aware of PNPM catalog references. A \"catalog:\" or \"catalog:\" specifier is now compared using the version that it resolves to in the catalogs of the project's subspace, so it is no longer reported as inconsistent with the same literal version or with \"preferredVersions\". \"rush check\" annotates versions that come from a catalog, and \"rush check --json\" adds an optional \"catalogReferences\" field. \"--make-consistent\" no longer replaces catalog references in other projects with literal versions.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts b/libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts index a9ea220ec72..1c507e755d5 100644 --- a/libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts +++ b/libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts @@ -1,13 +1,20 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as path from 'node:path'; + +import { AnsiEscape } from '@rushstack/terminal'; + import type { RushConfigurationProject } from '../RushConfigurationProject'; import { VersionMismatchFinder } from '../../logic/versionMismatch/VersionMismatchFinder'; -import { PackageJsonEditor } from '../PackageJsonEditor'; +import { DependencyType, PackageJsonEditor } from '../PackageJsonEditor'; import { CommonVersionsConfiguration } from '../CommonVersionsConfiguration'; import type { VersionMismatchFinderEntity } from '../../logic/versionMismatch/VersionMismatchFinderEntity'; import { VersionMismatchFinderProject } from '../../logic/versionMismatch/VersionMismatchFinderProject'; import { VersionMismatchFinderCommonVersions } from '../../logic/versionMismatch/VersionMismatchFinderCommonVersions'; +import type { PnpmCatalogs } from '../../logic/pnpm/EffectivePnpmOptions'; +import { RushConfiguration } from '../RushConfiguration'; +import type { Subspace } from '../Subspace'; /* eslint-disable @typescript-eslint/no-explicit-any */ describe(VersionMismatchFinder.name, () => { @@ -443,4 +450,273 @@ describe(VersionMismatchFinder.name, () => { expect(mismatchFinder.getConsumersOfMismatch('@scope/library-1', '~3.2.1')).toEqual([projectB]); expect(mismatchFinder.getConsumersOfMismatch('@scope/library-1', '1.2.3')).toEqual([projectA]); }); + + describe('PNPM catalog references', () => { + const CATALOGS: PnpmCatalogs = { + default: { + typescript: '~5.8.2' + }, + react17: { + react: '^17.0.2' + } + }; + + function createProject( + packageName: string, + dependencies: Record, + pnpmCatalogs: PnpmCatalogs | undefined = CATALOGS + ): VersionMismatchFinderProject { + return new VersionMismatchFinderProject( + { + packageName, + packageJsonEditor: PackageJsonEditor.fromObject({ dependencies } as any, 'foo.json'), + decoupledLocalDependencies: new Set() + } as any as RushConfigurationProject, + pnpmCatalogs + ); + } + + it('finds no mismatch between a catalog reference and the same version', () => { + const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' }); + const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: '~5.8.2' }); + + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]); + expect(mismatchFinder.numberOfMismatches).toEqual(0); + }); + + it('treats "catalog:" and "catalog:default" as the same reference', () => { + const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' }); + const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: 'catalog:default' }); + + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]); + expect(mismatchFinder.numberOfMismatches).toEqual(0); + }); + + it('finds a mismatch between a catalog reference and a different version', () => { + const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' }); + const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: '~5.7.0' }); + + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]); + expect(mismatchFinder.getMismatches()).toEqual(['typescript']); + expect(mismatchFinder.getVersionsOfMismatch('typescript')).toEqual(['~5.8.2', '~5.7.0']); + expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.8.2')).toEqual([projectA]); + expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.7.0')).toEqual([projectB]); + expect(mismatchFinder.tryGetCatalogVersionSpecifier('typescript', '~5.8.2', projectA)).toEqual( + 'catalog:' + ); + expect(mismatchFinder.tryGetCatalogVersionSpecifier('typescript', '~5.7.0', projectB)).toBeUndefined(); + }); + + it('compares a catalog reference with the preferred versions', async () => { + const commonVersionsConfiguration: CommonVersionsConfiguration = + await CommonVersionsConfiguration.loadFromFileAsync(`${__dirname}/jsonFiles/common-versions.json`); + const commonVersions: VersionMismatchFinderEntity = new VersionMismatchFinderCommonVersions( + commonVersionsConfiguration + ); + const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' }); + + commonVersionsConfiguration.preferredVersions.set('typescript', '~5.8.2'); + expect(new VersionMismatchFinder([commonVersions, projectA]).numberOfMismatches).toEqual(0); + + commonVersionsConfiguration.preferredVersions.set('typescript', '~5.7.0'); + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([commonVersions, projectA]); + expect(mismatchFinder.getMismatches()).toEqual(['typescript']); + expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.7.0')).toEqual([commonVersions]); + expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.8.2')).toEqual([projectA]); + }); + + it('resolves named catalogs and allows alternative versions of the resolved version', () => { + const projectA: VersionMismatchFinderEntity = createProject('A', { react: 'catalog:react17' }); + const projectB: VersionMismatchFinderEntity = createProject('B', { react: '^18.2.0' }); + + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]); + expect(mismatchFinder.getVersionsOfMismatch('react')).toEqual(['^17.0.2', '^18.2.0']); + + for (const allowedAlternativeVersion of ['^17.0.2', 'catalog:react17']) { + const alternatives: Map> = new Map([ + ['react', [allowedAlternativeVersion]] + ]); + expect(new VersionMismatchFinder([projectA, projectB], alternatives).numberOfMismatches).toEqual(0); + } + }); + + it("resolves each project's catalog references with that project's catalogs", () => { + const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' }); + const projectB: VersionMismatchFinderEntity = createProject( + 'B', + { typescript: 'catalog:' }, + { default: { typescript: '~5.7.0' } } + ); + + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]); + expect(mismatchFinder.getVersionsOfMismatch('typescript')).toEqual(['~5.8.2', '~5.7.0']); + expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.7.0')).toEqual([projectB]); + }); + + it('reports a catalog reference that does not resolve as its own version', () => { + const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' }); + const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: 'catalog:missing' }); + const projectC: VersionMismatchFinderEntity = createProject('C', { lodash: 'catalog:' }); + const projectD: VersionMismatchFinderEntity = createProject('D', { lodash: 'catalog:default' }); + const projectE: VersionMismatchFinderEntity = createProject('E', { lodash: '^4.17.21' }, undefined); + const projectF: VersionMismatchFinderEntity = createProject('F', { lodash: 'catalog:' }, undefined); + + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([ + projectA, + projectB, + projectC, + projectD, + projectE, + projectF + ]); + expect(mismatchFinder.getMismatches()).toEqual(['typescript', 'lodash']); + expect(mismatchFinder.getVersionsOfMismatch('typescript')).toEqual(['~5.8.2', 'catalog:missing']); + expect(mismatchFinder.getConsumersOfMismatch('typescript', 'catalog:missing')).toEqual([projectB]); + expect(mismatchFinder.getVersionsOfMismatch('lodash')).toEqual(['catalog:', '^4.17.21']); + expect(mismatchFinder.getConsumersOfMismatch('lodash', 'catalog:')).toEqual([ + projectC, + projectD, + projectF + ]); + expect(mismatchFinder.tryGetCatalogVersionSpecifier('lodash', 'catalog:', projectD)).toEqual( + 'catalog:default' + ); + }); + + describe('reporting', () => { + function createMismatchFinder(): VersionMismatchFinder { + return new VersionMismatchFinder([ + createProject('A', { typescript: 'catalog:', lodash: 'catalog:' }), + createProject('B', { typescript: 'catalog:default' }), + createProject('C', { typescript: '~5.7.0', lodash: '^4.17.21' }), + createProject('D', { typescript: '~5.8.2', lodash: 'catalog:default' }) + ]); + } + + function captureConsoleLog(callback: () => void): string[] { + const lines: string[] = []; + const consoleLogSpy: jest.SpyInstance = jest + .spyOn(console, 'log') + .mockImplementation((message: string = '') => { + lines.push(AnsiEscape.removeCodes(message)); + }); + try { + callback(); + } finally { + consoleLogSpy.mockRestore(); + } + + return lines; + } + + it('annotates versions that come from a catalog', () => { + const lines: string[] = captureConsoleLog(() => createMismatchFinder().print()); + expect(lines).toEqual([ + 'lodash', + ' catalog: (unresolved PNPM catalog reference)', + ' - A', + ' - D (catalog:default)', + ' ^4.17.21', + ' - C', + '', + 'typescript', + ' ~5.8.2 (catalog:)', + ' - A (catalog:)', + ' - B (catalog:default)', + ' - D', + ' ~5.7.0', + ' - C', + '' + ]); + }); + + it('reports the resolved version and the catalog references as JSON', () => { + const lines: string[] = captureConsoleLog(() => createMismatchFinder().printAsJson()); + expect(JSON.parse(lines.join('\n'))).toEqual({ + mismatchedVersions: [ + { + dependencyName: 'lodash', + versions: [ + { + version: 'catalog:', + projects: ['A', 'D'], + catalogReferences: [ + { project: 'A', versionSpecifier: 'catalog:' }, + { project: 'D', versionSpecifier: 'catalog:default' } + ] + }, + { version: '^4.17.21', projects: ['C'] } + ] + }, + { + dependencyName: 'typescript', + versions: [ + { + version: '~5.8.2', + projects: ['A', 'B', 'D'], + catalogReferences: [ + { project: 'A', versionSpecifier: 'catalog:' }, + { project: 'B', versionSpecifier: 'catalog:default' } + ] + }, + { version: '~5.7.0', projects: ['C'] } + ] + } + ] + }); + }); + }); + + describe(VersionMismatchFinder.getMismatches.name, () => { + function loadRushConfiguration(repoName: string): RushConfiguration { + return RushConfiguration.loadFromConfigurationFile( + path.resolve(__dirname, '../../logic/test', repoName, 'rush.json') + ); + } + + it('resolves catalog references with the catalogs of the subspace', () => { + const rushConfiguration: RushConfiguration = loadRushConfiguration('repoWithCatalogs'); + expect(VersionMismatchFinder.getMismatches(rushConfiguration).numberOfMismatches).toEqual(0); + + rushConfiguration + .getProjectByName('c')! + .packageJsonEditor.addOrUpdateDependency('semver', '^7.0.0', DependencyType.Regular); + rushConfiguration + .getProjectByName('c')! + .packageJsonEditor.addOrUpdateDependency('typescript', '~5.3.0', DependencyType.Dev); + + const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches(rushConfiguration); + expect(mismatchFinder.getMismatches()).toEqual(['semver']); + expect( + mismatchFinder.getConsumersOfMismatch('semver', '^7.5.4')!.map(({ friendlyName }) => friendlyName) + ).toEqual(['a', 'd']); + }); + + it.each<[string, string, string, number]>([ + ['default', 'react', '^18.0.0', 0], + ['other-subspace', 'foo', '~2.0.0', 0], + ['own-config-subspace', 'react', '^17.0.0', 0], + ['own-config-subspace', 'react', '^18.0.0', 1] + ])( + 'in subspace %s, compares %s with the preferred version %s', + (subspaceName: string, packageName: string, preferredVersion: string, expectedMismatches: number) => { + const rushConfiguration: RushConfiguration = loadRushConfiguration( + 'repoWithSubspacesCommonCatalogs' + ); + const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); + subspace.getCommonVersions().preferredVersions.set(packageName, preferredVersion); + + const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( + rushConfiguration, + { subspace, variant: undefined } + ); + expect(mismatchFinder.numberOfMismatches).toEqual(expectedMismatches); + if (expectedMismatches > 0) { + // own-config-subspace uses its own catalogs instead of the common catalogs + expect(mismatchFinder.getVersionsOfMismatch(packageName)).toEqual([preferredVersion, '^17.0.0']); + } + } + ); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/PackageJsonUpdater.ts b/libraries/rush-lib/src/logic/PackageJsonUpdater.ts index c6e1f49cf8c..76b0471ddb3 100644 --- a/libraries/rush-lib/src/logic/PackageJsonUpdater.ts +++ b/libraries/rush-lib/src/logic/PackageJsonUpdater.ts @@ -31,6 +31,7 @@ import { } from './PackageJsonUpdaterTypes'; import type { Subspace } from '../api/Subspace'; import { MAKE_CONSISTENT_FLAG_NAME } from '../cli/actions/AddAction'; +import { tryParsePnpmCatalogName } from './pnpm/PnpmCatalogReference'; /** * Options for adding a dependency to a particular project. @@ -103,6 +104,10 @@ export class PackageJsonUpdater { readonly #terminal: ITerminal; readonly #rushConfiguration: RushConfiguration; readonly #rushGlobalFolder: RushGlobalFolder; + /** + * Catalog references that were already reported as not updated, to avoid repeating the warning. + */ + readonly #reportedCatalogReferences: Set; public constructor( terminal: ITerminal, @@ -112,6 +117,7 @@ export class PackageJsonUpdater { this.#terminal = terminal; this.#rushConfiguration = rushConfiguration; this.#rushGlobalFolder = rushGlobalFolder; + this.#reportedCatalogReferences = new Set(); } /** @@ -330,9 +336,7 @@ 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): Promise { const { projects } = options; const { DependencyAnalyzer } = await import( @@ -462,8 +466,27 @@ export class PackageJsonUpdater { const projectsByVersion: ReadonlyMap> | undefined = mismatches.get(packageName); if (projectsByVersion) { - for (const consumers of projectsByVersion.values()) { + 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. + const catalogVersionSpecifier: string | undefined = this.#tryGetCatalogVersionSpecifierToUpdate( + consumer, + packageName + ); + if (catalogVersionSpecifier !== undefined) { + if (existingVersion !== version) { + this.#reportCatalogReferenceNotUpdated( + consumer, + packageName, + catalogVersionSpecifier, + existingVersion + ); + } + + continue; + } + result.push({ project: consumer, dependenciesToAddOrUpdateOrRemove: { @@ -478,12 +501,46 @@ export class PackageJsonUpdater { return result; } + /** + * If {@link PackageJsonUpdater.updateProject} would overwrite a PNPM catalog reference (such as `catalog:`) + * when it updates the package in the project, returns that version specifier. + */ + #tryGetCatalogVersionSpecifierToUpdate( + project: VersionMismatchFinderEntity, + 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) + )?.version; + return versionSpecifier !== undefined && tryParsePnpmCatalogName(versionSpecifier) !== undefined + ? versionSpecifier + : undefined; + } + + #reportCatalogReferenceNotUpdated( + project: VersionMismatchFinderEntity, + packageName: string, + catalogVersionSpecifier: string, + catalogVersion: string + ): void { + const key: string = `${project.friendlyName}\0${packageName}`; + if (this.#reportedCatalogReferences.has(key)) { + return; + } + + 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.` + ); + } + /** * Remove a dependency from a particular project. The core business logic for "rush remove". */ - async #doRushRemoveAsync( - options: IPackageJsonUpdaterRushRemoveOptions - ): Promise { + async #doRushRemoveAsync(options: IPackageJsonUpdaterRushRemoveOptions): Promise { const { projects, packagesToUpdate } = options; this.#terminal.writeLine(); @@ -803,9 +860,7 @@ export class PackageJsonUpdater { return normalizedVersion; } - #collectAllDownstreamDependencies( - project: RushConfigurationProject - ): Set { + #collectAllDownstreamDependencies(project: RushConfigurationProject): Set { const allProjectDownstreamDependencies: Set = new Set(); diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index ef8cbe0868f..08b3ac023da 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -34,8 +34,8 @@ import type { RushConfigurationProject } from '../api/RushConfigurationProject'; import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile'; import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile'; import { Git } from './Git'; -import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier'; import type { IPnpmOptionsJson } from './pnpm/PnpmOptionsConfiguration'; +import { tryParsePnpmCatalogName } from './pnpm/PnpmCatalogReference'; import { getCommonPnpmConfigFilePath, getEffectivePnpmOptions, @@ -763,10 +763,8 @@ export class ProjectChangeAnalyzer { ); for (const [depName, depVersion] of allDependencies) { - const specifier: DependencySpecifier = DependencySpecifier.parseWithCache(depName, depVersion); - if (specifier.specifierType === DependencySpecifierType.Catalog) { - // versionSpecifier holds the catalog name (empty string for "catalog:") - const catalogName: string = specifier.versionSpecifier || 'default'; + const catalogName: string | undefined = tryParsePnpmCatalogName(depVersion); + if (catalogName !== undefined) { const changedPkgs: Set | undefined = changedCatalogPackages.get(catalogName); if (changedPkgs?.has(depName)) { changedProjects.add(project); diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts b/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts index 7795096f766..1a578ceac10 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts @@ -48,6 +48,16 @@ export function tryParsePnpmCatalogName(versionSpecifier: string): string | unde return catalogName || DEFAULT_PNPM_CATALOG_NAME; } +/** + * Returns the canonical version specifier that references the specified catalog: `catalog:` for the + * default catalog, otherwise `catalog:`. + */ +export function formatPnpmCatalogSpecifier(catalogName: string): string { + return catalogName === DEFAULT_PNPM_CATALOG_NAME + ? PNPM_CATALOG_PROTOCOL + : `${PNPM_CATALOG_PROTOCOL}${catalogName}`; +} + /** * Returns the version specifier of the entry for the package in the specified catalog, or `undefined` * if that catalog does not exist or does not have an entry for the package. diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts index 1d2fb46bece..7505e6225bd 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_PNPM_CATALOG_NAME, + formatPnpmCatalogSpecifier, tryGetPnpmCatalogVersion, tryParsePnpmCatalogName, tryResolvePnpmCatalogReference @@ -49,6 +50,20 @@ describe('PnpmCatalogReference', () => { }); }); + describe(formatPnpmCatalogSpecifier.name, () => { + it('formats the canonical catalog reference', () => { + expect(formatPnpmCatalogSpecifier(DEFAULT_PNPM_CATALOG_NAME)).toBe('catalog:'); + expect(formatPnpmCatalogSpecifier('tools')).toBe('catalog:tools'); + }); + + it('round-trips with tryParsePnpmCatalogName', () => { + for (const versionSpecifier of ['catalog:', 'catalog:default', 'catalog: tools ']) { + const catalogName: string = tryParsePnpmCatalogName(versionSpecifier)!; + expect(tryParsePnpmCatalogName(formatPnpmCatalogSpecifier(catalogName))).toBe(catalogName); + } + }); + }); + describe(tryGetPnpmCatalogVersion.name, () => { it('returns the version from the catalog entry', () => { expect(tryGetPnpmCatalogVersion(CATALOGS, 'default', 'typescript')).toBe('~5.8.2'); diff --git a/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts b/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts new file mode 100644 index 00000000000..4d84b14ceb1 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/PackageJsonUpdater.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +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 { PackageJsonUpdater } from '../PackageJsonUpdater'; +import { type IPackageJsonUpdaterRushAddOptions, SemVerStyle } from '../PackageJsonUpdaterTypes'; +import { VersionMismatchFinderProject } from '../versionMismatch/VersionMismatchFinderProject'; +import { VersionMismatchFinderCommonVersions } from '../versionMismatch/VersionMismatchFinderCommonVersions'; + +describe(PackageJsonUpdater.name, () => { + describe('with PNPM catalog references', () => { + let rushConfiguration: RushConfiguration; + let terminalProvider: StringBufferTerminalProvider; + let packageJsonUpdater: PackageJsonUpdater; + + 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() + ); + }); + + 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'); + + expect(getVersionSpecifier('c', 'semver')).toEqual('^7.6.0'); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); + + 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( + 'Not updating "semver" in "d" because it uses the PNPM catalog reference "catalog:" (^7.5.4).' + ); + }); + + it('updates other projects without reporting catalog references that are already consistent', async () => { + rushConfiguration + .getProjectByName('b')! + .packageJsonEditor.addOrUpdateDependency('semver', '~7.0.0', DependencyType.Regular); + + await addWithMakeConsistentAsync(rushConfiguration.getProjectByName('c')!, 'semver', '^7.5.4'); + + expect(getVersionSpecifier('b', 'semver')).toEqual('^7.5.4'); + expect(getVersionSpecifier('c', 'semver')).toEqual('^7.5.4'); + expect(getVersionSpecifier('a', 'semver')).toEqual('catalog:'); + expect(getVersionSpecifier('d', 'semver')).toEqual('catalog:'); + expect(terminalProvider.getWarningOutput()).toEqual(''); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index 7c678010442..57cc15dc0c0 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -1177,6 +1177,35 @@ describe(ProjectChangeAnalyzer.name, () => { expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); + it('normalizes catalog references the way PNPM does', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + rushConfiguration.getProjectByName('d')!.packageJson.dependencies!.semver = 'catalog:default'; + rushConfiguration.getProjectByName('e')!.packageJson.devDependencies!.eslint = 'catalog: tools '; + mockPnpmConfigChanged(); + // semver changed in the default catalog, and eslint changed in the tools catalog + mockOldCatalogs({ + default: { react: '^18.0.0', semver: '^7.4.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.40.0' } + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'd' uses "catalog:default" for semver (changed) + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); + // 'e' uses "catalog: tools " for eslint (changed) + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); + // 'b' uses "catalog:tools" for typescript only (unchanged) + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + }); + it('all catalog-using projects marked as changed when no old catalog existed', async () => { const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); mockPnpmConfigChanged(); diff --git a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts index 719924aae70..77662b41ff9 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinder.ts @@ -12,6 +12,13 @@ import { VersionMismatchFinderProject } from './VersionMismatchFinderProject'; import { VersionMismatchFinderCommonVersions } from './VersionMismatchFinderCommonVersions'; import { CustomTipId } from '../../api/CustomTipsConfiguration'; import type { Subspace } from '../../api/Subspace'; +import { getEffectivePnpmCatalogs, type PnpmCatalogs } from '../pnpm/EffectivePnpmOptions'; +import { + formatPnpmCatalogSpecifier, + type IPnpmCatalogReference, + tryParsePnpmCatalogName, + tryResolvePnpmCatalogReference +} from '../pnpm/PnpmCatalogReference'; const TRUNCATE_AFTER_PACKAGE_NAME_COUNT: number = 5; @@ -36,8 +43,27 @@ export interface IMismatchDependency { } export interface IMismatchDependencyVersion { + /** + * The version specifier. + * + * @remarks + * A PNPM catalog reference (such as `catalog:` or `catalog:tools`) is reported as the version from the + * catalog entry that it resolves to, because that is the version that PNPM installs. A catalog reference + * that does not resolve is reported as the catalog reference (`catalog:` for the default catalog, + * otherwise `catalog:`). + */ version: string; projects: string[]; + /** + * The projects that reference this version through a PNPM catalog, and the version specifier from each + * project's package.json. This is omitted if no project references this version through a catalog. + */ + catalogReferences?: IMismatchDependencyCatalogReference[]; +} + +export interface IMismatchDependencyCatalogReference { + project: string; + versionSpecifier: string; } export interface IMismatchDependencies { @@ -54,6 +80,11 @@ export class VersionMismatchFinder { */ #allowedAlternativeVersion: Map>; #mismatches: Map>; + /** + * For each mismatched dependency and version, the version specifier of each consumer that references + * the version through a PNPM catalog. + */ + #catalogVersionSpecifiers: Map>>; #projects: VersionMismatchFinderEntity[]; public constructor( @@ -62,6 +93,7 @@ export class VersionMismatchFinder { ) { this.#projects = projects; this.#mismatches = new Map>(); + this.#catalogVersionSpecifiers = new Map(); this.#allowedAlternativeVersion = allowedAlternativeVersions || new Map>(); this.#analyze(); } @@ -107,6 +139,10 @@ export class VersionMismatchFinder { /** * Populates a version mismatch finder object given a Rush Configuration. * Intentionally considers preferred versions. + * + * @remarks + * PNPM catalog references (such as `catalog:`) are compared by the version that they resolve to, using + * the catalogs that are in effect for the subspace. */ public static getMismatches( rushConfiguration: RushConfiguration, @@ -121,9 +157,13 @@ export class VersionMismatchFinder { // Make sure this one is first so it doesn't get truncated when a long list is printed projects.push(new VersionMismatchFinderCommonVersions(commonVersions)); + // 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); + // If subspace is specified, only go through projects in that subspace for (const project of subspace.getProjects()) { - projects.push(new VersionMismatchFinderProject(project)); + projects.push(new VersionMismatchFinderProject(project, pnpmCatalogs)); } return new VersionMismatchFinder(projects, commonVersions.allowedAlternativeVersions); @@ -159,6 +199,19 @@ export class VersionMismatchFinder { return mismatchedVersion; } + /** + * If the consumer references the mismatched version through a PNPM catalog, returns the version + * specifier from the consumer's package.json (such as `catalog:` or `catalog:tools`). Otherwise + * returns `undefined`. + */ + public tryGetCatalogVersionSpecifier( + mismatch: string, + version: string, + consumer: VersionMismatchFinderEntity + ): string | undefined { + return this.#catalogVersionSpecifiers.get(mismatch)?.get(version)?.get(consumer); + } + public printAsJson(): void { const mismatchDependencies: IMismatchDependency[] = []; @@ -166,13 +219,25 @@ export class VersionMismatchFinder { const mismatchDependencyVersionArray: IMismatchDependencyVersion[] = []; this.getVersionsOfMismatch(dependency)!.forEach((version: string) => { const projects: string[] = []; + const catalogReferences: IMismatchDependencyCatalogReference[] = []; this.getConsumersOfMismatch(dependency, version)!.forEach((project: VersionMismatchFinderEntity) => { projects.push(project.friendlyName); + const versionSpecifier: string | undefined = this.tryGetCatalogVersionSpecifier( + dependency, + version, + project + ); + if (versionSpecifier !== undefined) { + catalogReferences.push({ project: project.friendlyName, versionSpecifier }); + } }); const mismatchDependencyVersion: IMismatchDependencyVersion = { version: version, projects: projects }; + if (catalogReferences.length > 0) { + mismatchDependencyVersion.catalogReferences = catalogReferences; + } mismatchDependencyVersionArray.push(mismatchDependencyVersion); }); const mismatchDependency: IMismatchDependency = { @@ -196,26 +261,35 @@ export class VersionMismatchFinder { // eslint-disable-next-line no-console console.log(Colorize.yellow(dependency)); this.getVersionsOfMismatch(dependency)!.forEach((version: string) => { - // eslint-disable-next-line no-console - console.log(` ${version}`); const consumersOfMismatch: VersionMismatchFinderEntity[] = this.getConsumersOfMismatch( dependency, version )!; + // eslint-disable-next-line no-console + console.log(` ${version}${this.#getVersionAnnotation(dependency, version)}`); + let numberToPrint: number = truncateLongPackageNameLists ? TRUNCATE_AFTER_PACKAGE_NAME_COUNT : consumersOfMismatch.length; let numberRemaining: number = consumersOfMismatch.length; - for (const { friendlyName } of consumersOfMismatch) { + for (const consumer of consumersOfMismatch) { if (numberToPrint-- === 0) { break; } numberRemaining--; + const versionSpecifier: string | undefined = this.tryGetCatalogVersionSpecifier( + dependency, + version, + consumer + ); + const consumerAnnotation: string = + versionSpecifier !== undefined && versionSpecifier !== version ? ` (${versionSpecifier})` : ''; + // eslint-disable-next-line no-console - console.log(` - ${friendlyName}`); + console.log(` - ${consumer.friendlyName}${consumerAnnotation}`); } if (numberRemaining > 0) { @@ -228,6 +302,29 @@ export class VersionMismatchFinder { }); } + /** + * Describes where a mismatched version came from if any consumer references it through a PNPM catalog, + * for example ` (catalog:)` or ` (unresolved PNPM catalog reference)`. + */ + #getVersionAnnotation(dependency: string, version: string): string { + const catalogConsumers: Map | undefined = + this.#catalogVersionSpecifiers.get(dependency)?.get(version); + if (!catalogConsumers) { + return ''; + } + + if (tryParsePnpmCatalogName(version) !== undefined) { + return ' (unresolved PNPM catalog reference)'; + } + + const catalogSpecifiers: Set = new Set(); + for (const versionSpecifier of catalogConsumers.values()) { + catalogSpecifiers.add(formatPnpmCatalogSpecifier(tryParsePnpmCatalogName(versionSpecifier)!)); + } + + return ` (${Array.from(catalogSpecifiers).join(', ')})`; + } + #analyze(): void { this.#projects.forEach((project: VersionMismatchFinderEntity) => { if (!project.skipRushCheck) { @@ -240,11 +337,25 @@ export class VersionMismatchFinder { // before we get into that.) project.allDependencies.forEach((dependency: PackageJsonDependency) => { if (dependency.dependencyType !== DependencyType.Peer) { - const version: string = dependency.version!; + const versionSpecifier: string = dependency.version!; + + // Compare a PNPM catalog reference by the version that PNPM installs for it. A reference that + // does not resolve is compared as its canonical form, so that it is reported as its own version. + const catalogReference: IPnpmCatalogReference | undefined = tryResolvePnpmCatalogReference( + dependency.name, + versionSpecifier, + project.pnpmCatalogs + ); + const version: string = catalogReference + ? (catalogReference.resolvedVersion ?? formatPnpmCatalogSpecifier(catalogReference.catalogName)) + : versionSpecifier; const isCyclic: boolean = project.decoupledLocalDependencies.has(dependency.name); - if (this.#isVersionAllowedAlternative(dependency.name, version)) { + if ( + this.#isVersionAllowedAlternative(dependency.name, version) || + (catalogReference && this.#isVersionAllowedAlternative(dependency.name, versionSpecifier)) + ) { return; } @@ -265,6 +376,22 @@ export class VersionMismatchFinder { } else { consumers.push(project); } + + if (catalogReference) { + let catalogVersions: Map> | undefined = + this.#catalogVersionSpecifiers.get(name); + if (!catalogVersions) { + this.#catalogVersionSpecifiers.set(name, (catalogVersions = new Map())); + } + + let catalogConsumers: Map | undefined = + catalogVersions.get(version); + if (!catalogConsumers) { + catalogVersions.set(version, (catalogConsumers = new Map())); + } + + catalogConsumers.set(project, versionSpecifier); + } } }); } @@ -273,6 +400,7 @@ export class VersionMismatchFinder { this.#mismatches.forEach((mismatches: Map, project: string) => { if (mismatches.size <= 1) { this.#mismatches.delete(project); + this.#catalogVersionSpecifiers.delete(project); } }); } diff --git a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderEntity.ts b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderEntity.ts index 5b2284d97aa..4988f652926 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderEntity.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderEntity.ts @@ -2,23 +2,31 @@ // See LICENSE in the project root for license information. import type { PackageJsonDependency, DependencyType } from '../../api/PackageJsonEditor'; +import type { PnpmCatalogs } from '../pnpm/EffectivePnpmOptions'; export interface IVersionMismatchFinderEntityOptions { friendlyName: string; decoupledLocalDependencies: Set; skipRushCheck?: boolean; + /** + * The PNPM catalogs that resolve the entity's catalog references (such as `catalog:`), as returned by + * `getEffectivePnpmCatalogs()` for the subspace of the project. + */ + pnpmCatalogs?: PnpmCatalogs; } export abstract class VersionMismatchFinderEntity { public readonly friendlyName: string; public readonly decoupledLocalDependencies: Set; public readonly skipRushCheck: boolean | undefined; + public readonly pnpmCatalogs: PnpmCatalogs | undefined; public constructor(options: IVersionMismatchFinderEntityOptions) { - const { friendlyName, decoupledLocalDependencies, skipRushCheck } = options; + const { friendlyName, decoupledLocalDependencies, skipRushCheck, pnpmCatalogs } = options; this.friendlyName = friendlyName; this.decoupledLocalDependencies = decoupledLocalDependencies; this.skipRushCheck = skipRushCheck; + this.pnpmCatalogs = pnpmCatalogs; } public abstract get filePath(): string; diff --git a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderProject.ts b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderProject.ts index 78effefc747..42523032333 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderProject.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderProject.ts @@ -4,16 +4,22 @@ import { VersionMismatchFinderEntity } from './VersionMismatchFinderEntity'; import type { PackageJsonEditor, PackageJsonDependency, DependencyType } from '../../api/PackageJsonEditor'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import type { PnpmCatalogs } from '../pnpm/EffectivePnpmOptions'; export class VersionMismatchFinderProject extends VersionMismatchFinderEntity { public packageName: string; #fileManager: PackageJsonEditor; - public constructor(project: RushConfigurationProject) { + /** + * @param pnpmCatalogs - the catalogs that are in effect for the project's subspace. If omitted, catalog + * references (such as `catalog:`) are compared as unresolved references. + */ + public constructor(project: RushConfigurationProject, pnpmCatalogs?: PnpmCatalogs) { super({ friendlyName: project.packageName, decoupledLocalDependencies: project.decoupledLocalDependencies, - skipRushCheck: project.skipRushCheck + skipRushCheck: project.skipRushCheck, + pnpmCatalogs }); this.#fileManager = project.packageJsonEditor;