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 0000000000..470636089e --- /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 2d2408a9eb..7773b1d8b3 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 93ef26db11..9f389746be 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 0000000000..7795096f76 --- /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 8f2c4cebeb..4cb9e6e3d9 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 0000000000..1d2fb46bec --- /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 e54732aa3b..e56fdc60ee 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 23dafd9caf..c5779489ef 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 0000000000..dac0d8fff2 --- /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 0000000000..a37f7447be --- /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 0000000000..bb05c081e8 --- /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 0000000000..0276bd1727 --- /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 0000000000..47863ff0ca --- /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 0000000000..42402f3694 --- /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 0000000000..55bae5ebf3 --- /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 04f192fef8..e4dcaf334e 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'])]])); + }); + }); });