From ea8fcb2321c61ed8cc3a4f854c3998d691c89278 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:46:58 -0700 Subject: [PATCH] fix(rush-lib): resolve pnpm catalogs in preferred versions Implicitly preferred versions used the raw "catalog:" specifier, which made semver.subset() throw in rush install for cataloged packages listed in preferredVersions and disabled pnpmfile range narrowing for them. Also skip semver.subset() for specifiers that aren't SemVer ranges. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...e-preferred-versions_2026-10-13-01-00.json | 11 +++ .../rush-lib/src/api/RushConfiguration.ts | 4 ++ .../rush-lib/src/logic/DependencyAnalyzer.ts | 61 +++++++++++++++- .../src/logic/pnpm/PnpmCatalogReference.ts | 72 +++++++++++++++++++ .../src/logic/pnpm/PnpmfileConfiguration.ts | 11 ++- .../pnpm/test/PnpmCatalogReference.test.ts | 33 +++++++++ .../pnpm/test/PnpmShrinkwrapFile.test.ts | 26 +++++++ .../pnpm/test/PnpmfileConfiguration.test.ts | 53 +++++++++++++- .../repoWithCatalogs/apps/app-a/package.json | 12 ++++ .../common/config/rush/common-versions.json | 9 +++ .../common/config/rush/pnpm-config.json | 13 ++++ .../repoWithCatalogs/libs/lib-b/package.json | 14 ++++ .../pnpm/test/repoWithCatalogs/rush.json | 17 +++++ .../pnpm-lock-v9/catalogs-not-modified.yaml | 65 +++++++++++++++++ .../src/logic/test/DependencyAnalyzer.test.ts | 47 ++++++++++++ 15 files changed, 443 insertions(+), 5 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-catalog-aware-preferred-versions_2026-10-13-01-00.json create mode 100644 libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts create mode 100644 libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/apps/app-a/package.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/common-versions.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/libs/lib-b/package.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/rush.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/catalogs-not-modified.yaml diff --git a/common/changes/@microsoft/rush/bmiddha-catalog-aware-preferred-versions_2026-10-13-01-00.json b/common/changes/@microsoft/rush/bmiddha-catalog-aware-preferred-versions_2026-10-13-01-00.json new file mode 100644 index 00000000000..3a18d0f72d0 --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-catalog-aware-preferred-versions_2026-10-13-01-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix a \"TypeError: Invalid comparator\" crash in \"rush install\" when a package in \"preferredVersions\" is referenced through a PNPM catalog or a non-SemVer specifier.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index 2d2408a9eb5..d5405c737e1 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -1378,6 +1378,10 @@ export class RushConfiguration { /** * Returns a map of all direct dependencies that only have a single semantic version specifier. * + * @remarks + * PNPM catalog references are resolved using the catalogs of the declaring project's subspace. + * References without a catalog entry are ignored. + * * @param subspace - The subspace to use * @param variant - The name of the current variant in use by the active command. * diff --git a/libraries/rush-lib/src/logic/DependencyAnalyzer.ts b/libraries/rush-lib/src/logic/DependencyAnalyzer.ts index 93ef26db116..deb19764cbd 100644 --- a/libraries/rush-lib/src/logic/DependencyAnalyzer.ts +++ b/libraries/rush-lib/src/logic/DependencyAnalyzer.ts @@ -8,6 +8,8 @@ import { DependencyType, type PackageJsonDependency } from '../api/PackageJsonEd import type { RushConfiguration } from '../api/RushConfiguration'; import type { RushConfigurationProject } from '../api/RushConfigurationProject'; import type { Subspace } from '../api/Subspace'; +import { getEffectivePnpmCatalogs, type PnpmCatalogs } from './pnpm/EffectivePnpmOptions'; +import { tryGetPnpmCatalogVersion, tryParsePnpmCatalogName } from './pnpm/PnpmCatalogReference'; export interface IDependencyAnalysis { /** @@ -24,8 +26,17 @@ export interface IDependencyAnalysis { /** * A map of dependency name to the set of version specifiers used in the repo. + * + * @remarks + * PNPM catalog references are replaced by the catalog version from the declaring project's subspace. + * References without a catalog entry are skipped. */ allVersionsByPackageName: Map>; + + /** + * A map of dependency name to the PNPM catalogs it is referenced through, e.g. `default` for `catalog:`. + */ + catalogNamesByPackageName: Map>; } let _dependencyAnalyzerByRushConfiguration: WeakMap | undefined; @@ -103,6 +114,24 @@ export class DependencyAnalyzer { ReadonlyArray > = commonVersionsConfiguration.allowedAlternativeVersions; + const catalogNamesByPackageName: Map> = new Map(); + + // Catalogs can differ per subspace. Load them lazily, since most projects don't use catalogs. + const catalogsBySubspace: Map = new Map(); + const getCatalogsForProject: (project: RushConfigurationProject) => PnpmCatalogs | undefined = ( + project: RushConfigurationProject + ) => { + const projectSubspace: Subspace = project.subspace; + if (!catalogsBySubspace.has(projectSubspace)) { + catalogsBySubspace.set( + projectSubspace, + getEffectivePnpmCatalogs(projectSubspace, this.#rushConfiguration) + ); + } + + return catalogsBySubspace.get(projectSubspace); + }; + let projectsToProcess: RushConfigurationProject[] = this.#rushConfiguration.projects; if (addAction && this.#rushConfiguration.subspacesFeatureEnabled) { projectsToProcess = subspace.getProjects(); @@ -113,13 +142,29 @@ export class DependencyAnalyzer { ...project.packageJsonEditor.dependencyList, ...project.packageJsonEditor.devDependencyList ]; - for (const { name: dependencyName, version: dependencyVersion, dependencyType } of dependencies) { + for (const { name: dependencyName, version: specifiedVersion, dependencyType } of dependencies) { if (dependencyType === DependencyType.Peer) { // If this is a peer dependency, it isn't a real dependency in this context, so it shouldn't // be included in the list of dependency versions. continue; } + let dependencyVersion: string = specifiedVersion; + const catalogName: string | undefined = tryParsePnpmCatalogName(specifiedVersion); + if (catalogName !== undefined) { + const catalogVersion: string | undefined = tryGetPnpmCatalogVersion( + getCatalogsForProject(project), + catalogName, + dependencyName + ); + if (catalogVersion === undefined) { + // PNPM fails the install for this reference, so don't treat it as a version + continue; + } + + dependencyVersion = catalogVersion; + } + if (dependencyVersion.startsWith('workspace:')) { // If this is a workspace protocol dependency, ignore it. continue; @@ -145,6 +190,17 @@ export class DependencyAnalyzer { } allVersionForDependency.add(dependencyVersion); + + if (catalogName !== undefined) { + let catalogNamesForDependency: Set | undefined = + catalogNamesByPackageName.get(dependencyName); + if (!catalogNamesForDependency) { + catalogNamesForDependency = new Set(); + catalogNamesByPackageName.set(dependencyName, catalogNamesForDependency); + } + + catalogNamesForDependency.add(catalogName); + } } } @@ -190,7 +246,8 @@ export class DependencyAnalyzer { return { commonVersionsConfiguration, implicitlyPreferredVersionByPackageName, - allVersionsByPackageName + allVersionsByPackageName, + catalogNamesByPackageName }; } } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts b/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts new file mode 100644 index 00000000000..fe40214567c --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { PnpmCatalogs } from './EffectivePnpmOptions'; + +export const PNPM_CATALOG_PROTOCOL: 'catalog:' = 'catalog:'; + +export const DEFAULT_PNPM_CATALOG_NAME: 'default' = 'default'; + +export interface IPnpmCatalogReference { + /** + * The normalized catalog name; `catalog:` and `catalog:default` are both `default`. + */ + readonly catalogName: string; + + /** + * The version from the catalog entry, or `undefined` if the catalog has no entry for the package. + */ + readonly resolvedVersion: string | undefined; +} + +/** + * Returns the normalized catalog name if the specifier is a PNPM catalog reference, otherwise `undefined`. + * Like PNPM, the name is trimmed and an empty name means `default`. + */ +export function tryParsePnpmCatalogName(versionSpecifier: string): string | undefined { + if (!versionSpecifier.startsWith(PNPM_CATALOG_PROTOCOL)) { + return undefined; + } + + const catalogName: string = versionSpecifier.slice(PNPM_CATALOG_PROTOCOL.length).trim(); + return catalogName || DEFAULT_PNPM_CATALOG_NAME; +} + +export function tryGetPnpmCatalogVersion( + catalogs: PnpmCatalogs | undefined, + catalogName: string, + packageName: string +): string | undefined { + // Own-property checks, so names like "constructor" don't resolve to Object.prototype members + if (!catalogs || !Object.prototype.hasOwnProperty.call(catalogs, catalogName)) { + return undefined; + } + + const catalog: Record = catalogs[catalogName]; + if (!Object.prototype.hasOwnProperty.call(catalog, packageName)) { + return undefined; + } + + return catalog[packageName]; +} + +/** + * Returns `undefined` if the specifier isn't a catalog reference. + * + * @param catalogs - from `getEffectivePnpmCatalogs()` for the declaring project's subspace + */ +export function tryResolvePnpmCatalogReference( + packageName: string, + versionSpecifier: string, + catalogs: PnpmCatalogs | undefined +): IPnpmCatalogReference | undefined { + const catalogName: string | undefined = tryParsePnpmCatalogName(versionSpecifier); + if (catalogName === undefined) { + return undefined; + } + + return { + catalogName, + resolvedVersion: tryGetPnpmCatalogVersion(catalogs, catalogName, packageName) + }; +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts index 8f2c4cebebf..2774c38d1c8 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts @@ -114,8 +114,15 @@ async function _getPnpmfileShimSettingsAsync( rushConfiguration.getImplicitlyPreferredVersions(subspace, variant) ); for (const [name, version] of commonVersionsConfiguration.getAllPreferredVersions()) { - // Use the most restrictive version range available - if (!preferredVersions.has(name) || semver.subset(version, preferredVersions.get(name)!)) { + // Use the most restrictive version range available. semver.subset() throws for specifiers that + // aren't ranges (e.g. "npm:" aliases or git URLs); in that case the explicitly preferred version wins. + const implicitlyPreferredVersion: string | undefined = preferredVersions.get(name); + if ( + implicitlyPreferredVersion === undefined || + !semver.validRange(version) || + !semver.validRange(implicitlyPreferredVersion) || + semver.subset(version, implicitlyPreferredVersion) + ) { preferredVersions.set(name, version); } } diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts new file mode 100644 index 00000000000..4f7608cae3c --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + DEFAULT_PNPM_CATALOG_NAME, + tryGetPnpmCatalogVersion, + tryParsePnpmCatalogName +} from '../PnpmCatalogReference'; + +describe('PnpmCatalogReference', () => { + describe(tryParsePnpmCatalogName.name, () => { + it('normalizes catalog names like PNPM', () => { + expect(tryParsePnpmCatalogName('catalog:')).toBe(DEFAULT_PNPM_CATALOG_NAME); + expect(tryParsePnpmCatalogName('catalog: ')).toBe(DEFAULT_PNPM_CATALOG_NAME); + expect(tryParsePnpmCatalogName('catalog:default')).toBe(DEFAULT_PNPM_CATALOG_NAME); + expect(tryParsePnpmCatalogName('catalog: tools ')).toBe('tools'); + }); + + it('returns undefined for other specifiers', () => { + for (const versionSpecifier of ['~5.8.2', 'catalog', ' catalog:']) { + expect(tryParsePnpmCatalogName(versionSpecifier)).toBeUndefined(); + } + }); + }); + + describe(tryGetPnpmCatalogVersion.name, () => { + it('does not resolve Object.prototype members', () => { + const catalogs: Record> = { default: { typescript: '~5.8.2' } }; + expect(tryGetPnpmCatalogVersion(catalogs, 'constructor', 'name')).toBeUndefined(); + expect(tryGetPnpmCatalogVersion(catalogs, 'default', 'toString')).toBeUndefined(); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts index e54732aa3b6..e28ce4ed734 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts @@ -769,6 +769,22 @@ snapshots: ).resolves.toBe(false); }); + it('can detect not modified when dependencies reference catalogs', async () => { + // preferredVersions must not cause the pnpmfile to rewrite the "catalog:" specifiers + const project = getMockRushProjectWithCatalogs(); + const pnpmShrinkwrapFile = getPnpmShrinkwrapFileFromFile( + `${__dirname}/yamlFiles/pnpm-lock-v9/catalogs-not-modified.yaml`, + project.rushConfiguration.defaultSubspace + ); + await expect( + pnpmShrinkwrapFile.isWorkspaceProjectModifiedAsync( + project, + project.rushConfiguration.defaultSubspace, + undefined + ) + ).resolves.toBe(false); + }); + it('can detect a devDependency that is still listed under dependencies in the importer', async () => { // Regression: moving a dep from dependencies to devDependencies should be detected as modified. const project = getMockRushProject(); @@ -879,3 +895,13 @@ function getMockRushProject2(): RushConfigurationProject { } return project; } + +function getMockRushProjectWithCatalogs(): RushConfigurationProject { + const rushFilename: string = `${__dirname}/repoWithCatalogs/rush.json`; + const rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushFilename); + const project = rushConfiguration.projectsByName.get('app-a'); + if (!project) { + throw new Error(`Can not get project "app-a"`); + } + return project; +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmfileConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmfileConfiguration.test.ts index 23dafd9caf1..b6aa70a1061 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmfileConfiguration.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmfileConfiguration.test.ts @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { type IPackageJson, JsonFile, type JsonObject } from '@rushstack/node-core-library'; + import { RushConfiguration } from '../../../api/RushConfiguration'; import { PnpmfileConfiguration } from '../PnpmfileConfiguration'; -import { JsonFile, type JsonObject } from '@rushstack/node-core-library'; describe(PnpmfileConfiguration.name, () => { const repoPath: string = `${__dirname}/repo`; @@ -35,4 +36,54 @@ describe(PnpmfileConfiguration.name, () => { const shimJson: JsonObject = await JsonFile.loadAsync(shimPath); expect(shimJson.allPreferredVersions).toHaveProperty('find-up', '5.0.0'); }); + + describe('with PNPM catalogs', () => { + const catalogRushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${__dirname}/repoWithCatalogs/rush.json` + ); + const catalogShimPath: string = `${catalogRushConfiguration.defaultSubspace.getSubspaceTempFolderPath()}/pnpmfileSettings.json`; + + it('does not crash when a package in preferredVersions is referenced through a catalog', async () => { + const subspace = catalogRushConfiguration.defaultSubspace; + await PnpmfileConfiguration.writeCommonTempPnpmfileShimAsync( + catalogRushConfiguration, + subspace.getSubspaceTempFolderPath(), + subspace, + undefined + ); + + const shimJson: JsonObject = await JsonFile.loadAsync(catalogShimPath); + expect(shimJson.allPreferredVersions).toEqual({ + typescript: '~5.8.2', + tslib: '~2.3.1', + eslint: '~9.37.0', + // Not comparable as SemVer ranges, so the explicitly preferred versions win + 'aliased-dep': '^1.0.0', + 'git-dep': 'git+https://github.com/example/git-dep.git' + }); + }); + + it('narrows the dependency ranges of external packages to the catalog versions', async () => { + const pnpmfileConfiguration: PnpmfileConfiguration = await PnpmfileConfiguration.initializeAsync( + catalogRushConfiguration, + catalogRushConfiguration.defaultSubspace, + undefined + ); + + const transformed: IPackageJson = pnpmfileConfiguration.transform({ + name: 'some-external-package', + version: '1.0.0', + dependencies: { + typescript: '^5.0.0', + tslib: '^2.0.0', + eslint: '^9.0.0' + } + }); + expect(transformed.dependencies).toEqual({ + typescript: '~5.8.2', + tslib: '~2.3.1', + eslint: '~9.37.0' + }); + }); + }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/apps/app-a/package.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/apps/app-a/package.json new file mode 100644 index 00000000000..dac0d8fff2e --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/apps/app-a/package.json @@ -0,0 +1,12 @@ +{ + "name": "app-a", + "version": "1.0.0", + "dependencies": { + "lib-b": "workspace:*", + "tslib": "catalog:default", + "typescript": "catalog:" + }, + "devDependencies": { + "eslint": "catalog:tools" + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/common-versions.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/common-versions.json new file mode 100644 index 00000000000..a37f7447be9 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/common-versions.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "preferredVersions": { + "typescript": "~5.8.2", + "eslint": "~9.37.0", + "aliased-dep": "^1.0.0", + "git-dep": "git+https://github.com/example/git-dep.git" + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..bb05c081e88 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/common/config/rush/pnpm-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "useWorkspaces": true, + "globalCatalogs": { + "default": { + "typescript": "~5.8.2", + "tslib": "~2.3.1" + }, + "tools": { + "eslint": "~9.37.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/libs/lib-b/package.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/libs/lib-b/package.json new file mode 100644 index 00000000000..0276bd17278 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/libs/lib-b/package.json @@ -0,0 +1,14 @@ +{ + "name": "lib-b", + "version": "1.0.0", + "dependencies": { + "aliased-dep": "npm:real-dep@^1.0.0", + "git-dep": "^2.0.0", + "missing-entry": "catalog:", + "tslib": "catalog:" + }, + "devDependencies": { + "missing-catalog": "catalog:nonexistent", + "typescript": "~5.8.2" + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/rush.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/rush.json new file mode 100644 index 00000000000..47863ff0caa --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithCatalogs/rush.json @@ -0,0 +1,17 @@ +{ + "pnpmVersion": "9.15.0", + "rushVersion": "5.46.1", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 99, + + "projects": [ + { + "packageName": "app-a", + "projectFolder": "apps/app-a" + }, + { + "packageName": "lib-b", + "projectFolder": "libs/lib-b" + } + ] +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/catalogs-not-modified.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/catalogs-not-modified.yaml new file mode 100644 index 00000000000..55bae5ebf38 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/catalogs-not-modified.yaml @@ -0,0 +1,65 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + tslib: + specifier: ~2.3.1 + version: 2.3.1 + typescript: + specifier: ~5.8.2 + version: 5.8.3 + tools: + eslint: + specifier: ~9.37.0 + version: 9.37.0 + +importers: + .: {} + + ../../apps/app-a: + dependencies: + lib-b: + specifier: workspace:* + version: link:../../libs/lib-b + tslib: + specifier: catalog:default + version: 2.3.1 + typescript: + specifier: 'catalog:' + version: 5.8.3 + devDependencies: + eslint: + specifier: catalog:tools + version: 9.37.0 + +packages: + eslint@9.37.0: + resolution: + { + integrity: sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig== + } + + tslib@2.3.1: + resolution: + { + integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + } + + typescript@5.8.3: + resolution: + { + integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + } + engines: { node: '>=14.17' } + hasBin: true + +snapshots: + eslint@9.37.0: {} + + tslib@2.3.1: {} + + typescript@5.8.3: {} diff --git a/libraries/rush-lib/src/logic/test/DependencyAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/DependencyAnalyzer.test.ts index 04f192fef8a..fba353d6e2d 100644 --- a/libraries/rush-lib/src/logic/test/DependencyAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/DependencyAnalyzer.test.ts @@ -88,4 +88,51 @@ describe(DependencyAnalyzer.name, () => { } `); }); + + describe('PNPM catalogs', () => { + function getAnalysisForRushJson(rushJsonPath: string): IDependencyAnalysis { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); + return DependencyAnalyzer.forRushConfiguration(rushConfiguration).getAnalysis( + undefined, + undefined, + false + ); + } + + it('resolves catalog references, and ignores references without a catalog entry', () => { + const analysis: IDependencyAnalysis = getAnalysisForRushJson( + `${__dirname}/../pnpm/test/repoWithCatalogs/rush.json` + ); + expect(analysis.implicitlyPreferredVersionByPackageName).toEqual( + new Map([ + ['tslib', '~2.3.1'], + ['typescript', '~5.8.2'], + ['eslint', '~9.37.0'], + ['aliased-dep', 'npm:real-dep@^1.0.0'], + ['git-dep', '^2.0.0'] + ]) + ); + expect(analysis.catalogNamesByPackageName).toEqual( + new Map([ + ['tslib', new Set(['default'])], + ['typescript', new Set(['default'])], + ['eslint', new Set(['tools'])] + ]) + ); + }); + + it("resolves catalog references using the catalogs of each project's subspace", () => { + // "react": "catalog:" resolves to a different version in each subspace + const analysis: IDependencyAnalysis = getAnalysisForRushJson( + `${__dirname}/repoWithSubspacesCommonCatalogs/rush.json` + ); + expect(analysis.allVersionsByPackageName).toEqual( + new Map([ + ['react', new Set(['^18.0.0', '^17.0.0'])], + ['typescript', new Set(['~5.3.0'])], + ['foo', new Set(['~2.0.0'])] + ]) + ); + }); + }); });