From aa9f4b6bf5068d783fdd70370ba0eb842b416259 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 DependencyAnalyzer resolves `catalog:` specifiers using the catalogs of each project's subspace, so implicitly preferred versions are real ranges instead of "catalog:". This fixes the semver.subset crash in rush install for cataloged packages listed in preferredVersions and restores pnpmfile shim range narrowing. Unresolvable catalog references are ignored. PnpmfileConfiguration skips semver.subset when either side is not a valid range. Also records catalogNamesByPackageName for rush add. 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 | 5 + .../rush-lib/src/logic/DependencyAnalyzer.ts | 75 +++++++++- .../src/logic/pnpm/PnpmCatalogReference.ts | 96 +++++++++++++ .../src/logic/pnpm/PnpmfileConfiguration.ts | 12 +- .../pnpm/test/PnpmCatalogReference.test.ts | 105 ++++++++++++++ .../pnpm/test/PnpmShrinkwrapFile.test.ts | 43 ++++++ .../pnpm/test/PnpmfileConfiguration.test.ts | 81 ++++++++++- .../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-modified.yaml | 65 +++++++++ .../pnpm-lock-v9/catalogs-not-modified.yaml | 65 +++++++++ .../src/logic/test/DependencyAnalyzer.test.ts | 136 ++++++++++++++++++ 16 files changed, 754 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-modified.yaml 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..470636089e8 --- /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": "Resolve PNPM catalog references (such as \"catalog:\" or \"catalog:tools\") to the catalog versions when Rush calculates implicitly preferred versions. This fixes a \"TypeError: Invalid comparator\" crash in \"rush install\" when a package in the \"preferredVersions\" of common-versions.json is referenced through a catalog, and restores the narrowing of transitive dependency ranges for those packages. \"rush install\" also no longer crashes when a preferred version can't be compared as a SemVer range.", + "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..7773b1d8b38 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -1378,6 +1378,11 @@ export class RushConfiguration { /** * Returns a map of all direct dependencies that only have a single semantic version specifier. * + * @remarks + * PNPM catalog references (such as `catalog:` or `catalog:tools`) are resolved to the version specifier + * from the catalog that is in effect for the subspace of the project that declares the dependency. + * Catalog references that don't resolve to 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..9f389746bea 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 { /** @@ -19,13 +21,32 @@ export interface IDependencyAnalysis { * A map of all direct dependencies that only have a single semantic version specifier, * unless the {@link CommonVersionsConfiguration.implicitlyPreferredVersions} option * set to `false`. + * + * @remarks + * PNPM catalog references (such as `catalog:` or `catalog:tools`) are resolved to the version + * specifier from the catalog, so the values are never catalog references. */ implicitlyPreferredVersionByPackageName: Map; /** * A map of dependency name to the set of version specifiers used in the repo. + * + * @remarks + * PNPM catalog references are resolved using the catalogs of the subspace that the declaring project + * belongs to. A catalog reference that does not resolve (because the catalog has no entry for the + * package) is ignored; PNPM reports an error for it when it installs. */ allVersionsByPackageName: Map>; + + /** + * A map of dependency name to the set of names of the PNPM catalogs that projects reference the + * dependency through, for example `default` for `catalog:` and `tools` for `catalog:tools`. + * + * @remarks + * Only catalog references that resolve are included. A dependency that no project references + * through a catalog does not have an entry. + */ + catalogNamesByPackageName: Map>; } let _dependencyAnalyzerByRushConfiguration: WeakMap | undefined; @@ -103,6 +124,25 @@ export class DependencyAnalyzer { ReadonlyArray > = commonVersionsConfiguration.allowedAlternativeVersions; + const catalogNamesByPackageName: Map> = new Map(); + + // The catalogs can differ between subspaces, so each catalog reference must be resolved using the + // catalogs of the declaring project's subspace. These are only loaded for projects that 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 +153,32 @@ 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) { + // This is a PNPM catalog reference such as "catalog:" or "catalog:tools". Use the version + // from the catalog, since that is the version that PNPM installs. + const catalogVersion: string | undefined = tryGetPnpmCatalogVersion( + getCatalogsForProject(project), + catalogName, + dependencyName + ); + if (catalogVersion === undefined) { + // The catalog doesn't have an entry for this package. PNPM reports an error for this when it + // installs, so ignore it here rather than treating "catalog:" as a version specifier. + continue; + } + + dependencyVersion = catalogVersion; + } + if (dependencyVersion.startsWith('workspace:')) { // If this is a workspace protocol dependency, ignore it. continue; @@ -145,6 +204,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 +260,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..7795096f766 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts @@ -0,0 +1,96 @@ +// 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'; + +/** + * The prefix of a version specifier that references a PNPM catalog, e.g. `catalog:` or `catalog:tools`. + */ +export const PNPM_CATALOG_PROTOCOL: 'catalog:' = 'catalog:'; + +/** + * The name of the catalog that `catalog:` refers to. + */ +export const DEFAULT_PNPM_CATALOG_NAME: 'default' = 'default'; + +/** + * A package.json version specifier that references a PNPM catalog, and the catalog entry it resolves to. + */ +export interface IPnpmCatalogReference { + /** + * The normalized name of the referenced catalog. Both `catalog:` and `catalog:default` reference + * the catalog named `default`. + */ + readonly catalogName: string; + + /** + * The version specifier from the catalog entry for the package, for example `~5.8.2`. + * This is `undefined` if the referenced catalog does not exist or does not have an entry for the package. + * PNPM reports an error for such references when it installs. + */ + readonly resolvedVersion: string | undefined; +} + +/** + * If the version specifier references a PNPM catalog, returns the normalized name of the catalog. + * Otherwise, returns `undefined`. + * + * @remarks + * This matches how PNPM parses the catalog protocol: the catalog name is trimmed, and `catalog:` + * (with an empty name) references the `default` catalog, the same as `catalog: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; +} + +/** + * 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. + */ +export function tryGetPnpmCatalogVersion( + catalogs: PnpmCatalogs | undefined, + catalogName: string, + packageName: string +): string | undefined { + // Use own-property checks so that 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]; +} + +/** + * If the version specifier for the package references a PNPM catalog, returns the referenced catalog + * and the version specifier that the reference resolves to. Otherwise, returns `undefined`. + * + * @param packageName - the dependency package name, i.e. the key from a "dependencies" table + * @param versionSpecifier - the dependency version specifier, i.e. the value from a "dependencies" table + * @param catalogs - the catalogs that are in effect for the subspace of the project that declares the + * dependency, as returned by `getEffectivePnpmCatalogs()` + */ +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..4cb9e6e3d93 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts @@ -114,8 +114,16 @@ 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. If the two version specifiers can't be + // compared (for example, because one of them is an "npm:" alias or a git URL), 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..1d2fb46bece --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts @@ -0,0 +1,105 @@ +// 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, + tryResolvePnpmCatalogReference +} from '../PnpmCatalogReference'; +import type { PnpmCatalogs } from '../EffectivePnpmOptions'; + +const CATALOGS: PnpmCatalogs = { + default: { + typescript: '~5.8.2' + }, + tools: { + eslint: '~9.37.0' + } +}; + +describe('PnpmCatalogReference', () => { + describe(tryParsePnpmCatalogName.name, () => { + it('maps "catalog:" and "catalog:default" to the default catalog', () => { + expect(tryParsePnpmCatalogName('catalog:')).toBe(DEFAULT_PNPM_CATALOG_NAME); + expect(tryParsePnpmCatalogName('catalog:default')).toBe(DEFAULT_PNPM_CATALOG_NAME); + }); + + it('returns the name of a named catalog', () => { + expect(tryParsePnpmCatalogName('catalog:tools')).toBe('tools'); + }); + + it('trims the catalog name, like PNPM does', () => { + expect(tryParsePnpmCatalogName('catalog: tools ')).toBe('tools'); + expect(tryParsePnpmCatalogName('catalog: ')).toBe(DEFAULT_PNPM_CATALOG_NAME); + }); + + it('returns undefined for version specifiers that are not catalog references', () => { + for (const versionSpecifier of [ + '~5.8.2', + '', + 'workspace:*', + 'npm:typescript@~5.8.2', + 'file:../typescript', + 'catalog', + ' catalog:' + ]) { + expect(tryParsePnpmCatalogName(versionSpecifier)).toBeUndefined(); + } + }); + }); + + describe(tryGetPnpmCatalogVersion.name, () => { + it('returns the version from the catalog entry', () => { + expect(tryGetPnpmCatalogVersion(CATALOGS, 'default', 'typescript')).toBe('~5.8.2'); + expect(tryGetPnpmCatalogVersion(CATALOGS, 'tools', 'eslint')).toBe('~9.37.0'); + }); + + it('returns undefined if the catalog or the entry does not exist', () => { + expect(tryGetPnpmCatalogVersion(CATALOGS, 'default', 'eslint')).toBeUndefined(); + expect(tryGetPnpmCatalogVersion(CATALOGS, 'nonexistent', 'typescript')).toBeUndefined(); + expect(tryGetPnpmCatalogVersion(undefined, 'default', 'typescript')).toBeUndefined(); + }); + + it('does not resolve Object.prototype members', () => { + expect(tryGetPnpmCatalogVersion(CATALOGS, 'constructor', 'name')).toBeUndefined(); + expect(tryGetPnpmCatalogVersion(CATALOGS, 'default', 'toString')).toBeUndefined(); + }); + }); + + describe(tryResolvePnpmCatalogReference.name, () => { + it('resolves catalog references', () => { + expect(tryResolvePnpmCatalogReference('typescript', 'catalog:', CATALOGS)).toEqual({ + catalogName: 'default', + resolvedVersion: '~5.8.2' + }); + expect(tryResolvePnpmCatalogReference('typescript', 'catalog:default', CATALOGS)).toEqual({ + catalogName: 'default', + resolvedVersion: '~5.8.2' + }); + expect(tryResolvePnpmCatalogReference('eslint', 'catalog:tools', CATALOGS)).toEqual({ + catalogName: 'tools', + resolvedVersion: '~9.37.0' + }); + }); + + it('reports a catalog reference that does not resolve', () => { + expect(tryResolvePnpmCatalogReference('missing', 'catalog:', CATALOGS)).toEqual({ + catalogName: 'default', + resolvedVersion: undefined + }); + expect(tryResolvePnpmCatalogReference('typescript', 'catalog:nonexistent', CATALOGS)).toEqual({ + catalogName: 'nonexistent', + resolvedVersion: undefined + }); + expect(tryResolvePnpmCatalogReference('typescript', 'catalog:', undefined)).toEqual({ + catalogName: 'default', + resolvedVersion: undefined + }); + }); + + it('returns undefined for version specifiers that are not catalog references', () => { + expect(tryResolvePnpmCatalogReference('typescript', '~5.8.2', CATALOGS)).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..e56fdc60eeb 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,39 @@ snapshots: ).resolves.toBe(false); }); + it('can detect not modified when dependencies reference catalogs', async () => { + // The project's package.json and the lockfile both use "catalog:", "catalog:default", and + // "catalog:tools" specifiers, and common-versions.json has preferredVersions for some of them + 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 modified when a dependency was changed to reference a catalog', async () => { + // The lockfile records "typescript" with the "~5.8.2" specifier, but the project now uses "catalog:" + const project = getMockRushProjectWithCatalogs(); + const pnpmShrinkwrapFile = getPnpmShrinkwrapFileFromFile( + `${__dirname}/yamlFiles/pnpm-lock-v9/catalogs-modified.yaml`, + project.rushConfiguration.defaultSubspace + ); + await expect( + pnpmShrinkwrapFile.isWorkspaceProjectModifiedAsync( + project, + project.rushConfiguration.defaultSubspace, + undefined + ) + ).resolves.toBe(true); + }); + 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 +912,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..c5779489ef7 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,11 @@ // 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 type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; import { PnpmfileConfiguration } from '../PnpmfileConfiguration'; -import { JsonFile, type JsonObject } from '@rushstack/node-core-library'; describe(PnpmfileConfiguration.name, () => { const repoPath: string = `${__dirname}/repo`; @@ -35,4 +37,81 @@ describe(PnpmfileConfiguration.name, () => { const shimJson: JsonObject = await JsonFile.loadAsync(shimPath); expect(shimJson.allPreferredVersions).toHaveProperty('find-up', '5.0.0'); }); + + describe('with PNPM catalogs', () => { + // The catalogs define "typescript": "~5.8.2", "tslib": "~2.3.1", and "eslint": "~9.37.0" (in the "tools" + // catalog), and common-versions.json has preferredVersions for "typescript" and "eslint". Projects reference + // these packages through "catalog:", "catalog:default", and "catalog:tools". + 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', + // The implicitly preferred version is an "npm:" alias, which can't be compared with the explicitly + // preferred version, so the explicitly preferred version wins + 'aliased-dep': '^1.0.0', + // The explicitly preferred version is a git URL, which can't be compared with the implicitly + // preferred version, so the explicitly preferred version wins + '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' + }); + }); + + it("does not rewrite a workspace project's catalog references", async () => { + const pnpmfileConfiguration: PnpmfileConfiguration = await PnpmfileConfiguration.initializeAsync( + catalogRushConfiguration, + catalogRushConfiguration.defaultSubspace, + undefined + ); + + const project: RushConfigurationProject = catalogRushConfiguration.getProjectByName('app-a')!; + const transformed: IPackageJson = pnpmfileConfiguration.transform( + project.packageJsonEditor.saveToObject() + ); + expect(transformed.dependencies).toEqual({ + 'lib-b': 'workspace:*', + tslib: 'catalog:default', + typescript: 'catalog:' + }); + expect(transformed.devDependencies).toEqual({ + eslint: 'catalog:tools' + }); + }); + }); }); 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-modified.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/catalogs-modified.yaml new file mode 100644 index 00000000000..42402f36944 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/catalogs-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: ~5.8.2 + 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/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..e4dcaf334e0 100644 --- a/libraries/rush-lib/src/logic/test/DependencyAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/DependencyAnalyzer.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { RushConfiguration } from '../../api/RushConfiguration'; +import type { Subspace } from '../../api/Subspace'; import { DependencyAnalyzer, type IDependencyAnalysis } from '../DependencyAnalyzer'; describe(DependencyAnalyzer.name, () => { @@ -88,4 +89,139 @@ describe(DependencyAnalyzer.name, () => { } `); }); + + describe('PNPM catalogs', () => { + function getAnalysisForRushJson( + rushJsonPath: string, + subspaceName?: string, + addAction: boolean = false + ): IDependencyAnalysis { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); + const subspace: Subspace | undefined = subspaceName + ? rushConfiguration.getSubspace(subspaceName) + : undefined; + return DependencyAnalyzer.forRushConfiguration(rushConfiguration).getAnalysis( + subspace, + undefined, + addAction + ); + } + + it('resolves references to the default and named catalogs', () => { + const analysis: IDependencyAnalysis = getAnalysisForRushJson(`${__dirname}/repoWithCatalogs/rush.json`); + expect(analysis.allVersionsByPackageName).toEqual( + new Map([ + ['react', new Set(['^18.0.0'])], + ['semver', new Set(['^7.5.4'])], + ['typescript', new Set(['~5.3.0'])], + ['eslint', new Set(['^8.50.0'])] + ]) + ); + expect(analysis.implicitlyPreferredVersionByPackageName).toEqual( + new Map([ + ['react', '^18.0.0'], + ['semver', '^7.5.4'], + ['typescript', '~5.3.0'], + ['eslint', '^8.50.0'] + ]) + ); + expect(analysis.catalogNamesByPackageName).toEqual( + new Map([ + ['react', new Set(['default'])], + ['semver', new Set(['default'])], + ['typescript', new Set(['tools'])], + ['eslint', new Set(['tools'])] + ]) + ); + }); + + it('treats "catalog:default" like "catalog:", and ignores catalog references that do not resolve', () => { + // app-a uses "tslib": "catalog:default" and "typescript": "catalog:" + // lib-b uses "tslib": "catalog:", a literal "typescript": "~5.8.2", and references + // a catalog entry and a catalog that don't exist + const analysis: IDependencyAnalysis = getAnalysisForRushJson( + `${__dirname}/../pnpm/test/repoWithCatalogs/rush.json` + ); + expect(analysis.allVersionsByPackageName).toEqual( + new Map([ + ['tslib', new Set(['~2.3.1'])], + ['typescript', new Set(['~5.8.2'])], + ['eslint', new Set(['~9.37.0'])], + ['aliased-dep', new Set(['npm:real-dep@^1.0.0'])], + ['git-dep', new Set(['^2.0.0'])] + ]) + ); + 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", () => { + // a (default subspace) and e (own-config-subspace) both use "react": "catalog:", but the + // catalogs resolve it to different versions. c (other-subspace) falls back to the common catalogs. + 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'])], + ['lodash', new Set(['^4.17.0'])] + ]) + ); + expect(analysis.implicitlyPreferredVersionByPackageName).toEqual( + new Map([ + ['typescript', '~5.3.0'], + ['foo', '~2.0.0'], + ['lodash', '^4.17.0'] + ]) + ); + expect(analysis.catalogNamesByPackageName).toEqual( + new Map([ + ['react', new Set(['default'])], + ['typescript', new Set(['tools'])], + ['foo', new Set(['default'])] + ]) + ); + }); + + it('resolves catalog references using the subspace catalogs for "rush add"', () => { + const ownConfigAnalysis: IDependencyAnalysis = getAnalysisForRushJson( + `${__dirname}/repoWithSubspacesCommonCatalogs/rush.json`, + 'own-config-subspace', + true + ); + expect(ownConfigAnalysis.allVersionsByPackageName).toEqual(new Map([['react', new Set(['^17.0.0'])]])); + expect(ownConfigAnalysis.implicitlyPreferredVersionByPackageName).toEqual( + new Map([['react', '^17.0.0']]) + ); + + const otherAnalysis: IDependencyAnalysis = getAnalysisForRushJson( + `${__dirname}/repoWithSubspacesCommonCatalogs/rush.json`, + 'other-subspace', + true + ); + expect(otherAnalysis.implicitlyPreferredVersionByPackageName).toEqual( + new Map([ + ['foo', '~2.0.0'], + ['lodash', '^4.17.0'] + ]) + ); + expect(otherAnalysis.catalogNamesByPackageName).toEqual(new Map([['foo', new Set(['default'])]])); + }); + }); });