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 0000000000..aa380bf66a --- /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 a9ea220ec7..1c507e755d 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 c6e1f49cf8..76b0471ddb 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 ef8cbe0868..08b3ac023d 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 7795096f76..1a578ceac1 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 1d2fb46bec..7505e6225b 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 0000000000..4d84b14ceb --- /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 7c67801044..57cc15dc0c 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 719924aae7..77662b41ff 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 5b2284d97a..4988f65292 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 78effefc74..4252303233 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;