From f1bf2752a3eaac15a5340042cf8961837f226832 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 Version checks compared raw specifiers, so "catalog:" was reported as inconsistent with the same version elsewhere, which fails ensureConsistentVersions once a repo adopts PNPM catalogs. Compare catalog references by their resolved version, and keep --make-consistent from replacing them with literal versions. 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 | 164 ++++++++++++++++++ .../rush-lib/src/logic/PackageJsonUpdater.ts | 67 +++++-- .../src/logic/ProjectChangeAnalyzer.ts | 8 +- .../src/logic/pnpm/PnpmCatalogReference.ts | 9 + .../pnpm/test/PnpmCatalogReference.test.ts | 8 + .../src/logic/test/PackageJsonUpdater.test.ts | 103 +++++++++++ .../logic/test/ProjectChangeAnalyzer.test.ts | 29 ++++ .../versionMismatch/VersionMismatchFinder.ts | 128 +++++++++++++- .../VersionMismatchFinderEntity.ts | 6 +- .../VersionMismatchFinderProject.ts | 9 +- 11 files changed, 517 insertions(+), 25 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..cfca4bd9f5b --- /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 compare PNPM catalog references (such as \"catalog:\") by the version they resolve to, and stop \"--make-consistent\" from replacing them 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..626031baeb0 100644 --- a/libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts +++ b/libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts @@ -1,6 +1,10 @@ // 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'; @@ -8,6 +12,9 @@ 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,161 @@ 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 + ): VersionMismatchFinderProject { + return new VersionMismatchFinderProject( + { + packageName, + packageJsonEditor: PackageJsonEditor.fromObject({ dependencies } as any, 'foo.json'), + decoupledLocalDependencies: new Set() + } as any as RushConfigurationProject, + CATALOGS + ); + } + + it('compares catalog references by their resolved version', () => { + const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([ + createProject('A', { typescript: 'catalog:' }), + createProject('B', { typescript: 'catalog:default' }), + createProject('C', { typescript: '~5.8.2' }) + ]); + expect(mismatchFinder.numberOfMismatches).toEqual(0); + }); + + 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); + } + }); + + 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 resolved and unresolved catalog versions', () => { + 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, () => { + it.each([ + // Uses the common catalogs + ['default', '^18.0.0'], + // Uses its own catalogs instead of the common catalogs + ['own-config-subspace', '^17.0.0'] + ])( + 'resolves catalog references with the catalogs of subspace %s', + (subspaceName: string, preferredVersion: string) => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + path.resolve(__dirname, '../../logic/test/repoWithSubspacesCommonCatalogs/rush.json') + ); + const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); + subspace.getCommonVersions().preferredVersions.set('react', preferredVersion); + + const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches( + rushConfiguration, + { subspace, variant: undefined } + ); + expect(mismatchFinder.numberOfMismatches).toEqual(0); + } + ); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/PackageJsonUpdater.ts b/libraries/rush-lib/src/logic/PackageJsonUpdater.ts index c6e1f49cf8c..1884a103af9 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,7 @@ export class PackageJsonUpdater { readonly #terminal: ITerminal; readonly #rushConfiguration: RushConfiguration; readonly #rushGlobalFolder: RushGlobalFolder; + readonly #reportedCatalogReferences: Set; public constructor( terminal: ITerminal, @@ -112,6 +114,7 @@ export class PackageJsonUpdater { this.#terminal = terminal; this.#rushConfiguration = rushConfiguration; this.#rushGlobalFolder = rushGlobalFolder; + this.#reportedCatalogReferences = new Set(); } /** @@ -330,9 +333,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 +463,26 @@ 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) { + // Replacing a catalog reference with a literal version would detach the project from the catalog + 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 +497,42 @@ export class PackageJsonUpdater { return result; } + #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 +852,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 24e4252923c..bb43f6b1311 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, @@ -754,10 +754,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 fe40214567c..9365ed4007f 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts @@ -32,6 +32,15 @@ export function tryParsePnpmCatalogName(versionSpecifier: string): string | unde return catalogName || DEFAULT_PNPM_CATALOG_NAME; } +/** + * Returns `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}`; +} + export function tryGetPnpmCatalogVersion( catalogs: PnpmCatalogs | undefined, catalogName: string, 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 4f7608cae3c..cf3259ebdbc 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 } from '../PnpmCatalogReference'; @@ -23,6 +24,13 @@ 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'); + }); + }); + describe(tryGetPnpmCatalogVersion.name, () => { it('does not resolve Object.prototype members', () => { const catalogs: Record> = { default: { typescript: '~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 e111588a792..28f922e1754 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -1180,6 +1180,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..64ee375aa01 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,21 @@ export interface IMismatchDependency { } export interface IMismatchDependencyVersion { + /** + * For a PNPM catalog reference, the version from the catalog entry, or the reference itself + * (such as `catalog:tools`) if it does not resolve. + */ version: string; projects: string[]; + /** + * Projects that reference this version through a PNPM catalog. Omitted if there are none. + */ + catalogReferences?: IMismatchDependencyCatalogReference[]; +} + +export interface IMismatchDependencyCatalogReference { + project: string; + versionSpecifier: string; } export interface IMismatchDependencies { @@ -54,6 +74,10 @@ export class VersionMismatchFinder { */ #allowedAlternativeVersion: Map>; #mismatches: Map>; + /** + * dependency name -> version -> consumer -> the consumer's catalog reference + */ + #catalogVersionSpecifiers: Map>>; #projects: VersionMismatchFinderEntity[]; public constructor( @@ -62,6 +86,7 @@ export class VersionMismatchFinder { ) { this.#projects = projects; this.#mismatches = new Map>(); + this.#catalogVersionSpecifiers = new Map(); this.#allowedAlternativeVersion = allowedAlternativeVersions || new Map>(); this.#analyze(); } @@ -107,6 +132,7 @@ export class VersionMismatchFinder { /** * Populates a version mismatch finder object given a Rush Configuration. * Intentionally considers preferred versions. + * PNPM catalog references are compared by the version that they resolve to in the subspace's catalogs. */ public static getMismatches( rushConfiguration: RushConfiguration, @@ -121,9 +147,11 @@ export class VersionMismatchFinder { // Make sure this one is first so it doesn't get truncated when a long list is printed projects.push(new VersionMismatchFinderCommonVersions(commonVersions)); + const pnpmCatalogs: PnpmCatalogs | undefined = getEffectivePnpmCatalogs(subspace, rushConfiguration); + // 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 +187,18 @@ export class VersionMismatchFinder { return mismatchedVersion; } + /** + * Returns the consumer's catalog reference (such as `catalog:tools`) if it references the version + * through a PNPM catalog. + */ + 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 +206,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 +248,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 +289,28 @@ export class VersionMismatchFinder { }); } + /** + * Returns a suffix such as ` (catalog:)` or ` (unresolved PNPM catalog reference)`, or `''`. + */ + #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 +323,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 catalog reference by the version that PNPM installs. An unresolved reference keeps 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 +362,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 +386,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..f80b6ed6f1c 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderEntity.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderEntity.ts @@ -2,23 +2,27 @@ // 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; + 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..6051e95a755 100644 --- a/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderProject.ts +++ b/libraries/rush-lib/src/logic/versionMismatch/VersionMismatchFinderProject.ts @@ -4,16 +4,21 @@ 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 - If omitted, catalog references are treated as unresolved. + */ + public constructor(project: RushConfigurationProject, pnpmCatalogs?: PnpmCatalogs) { super({ friendlyName: project.packageName, decoupledLocalDependencies: project.decoupledLocalDependencies, - skipRushCheck: project.skipRushCheck + skipRushCheck: project.skipRushCheck, + pnpmCatalogs }); this.#fileManager = project.packageJsonEditor;