Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
5 changes: 5 additions & 0 deletions libraries/rush-lib/src/api/RushConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
75 changes: 73 additions & 2 deletions libraries/rush-lib/src/logic/DependencyAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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<string, string>;

/**
* 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<string, Set<string>>;

/**
* 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<string, Set<string>>;
}

let _dependencyAnalyzerByRushConfiguration: WeakMap<RushConfiguration, DependencyAnalyzer> | undefined;
Expand Down Expand Up @@ -103,6 +124,25 @@ export class DependencyAnalyzer {
ReadonlyArray<string>
> = commonVersionsConfiguration.allowedAlternativeVersions;

const catalogNamesByPackageName: Map<string, Set<string>> = 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<Subspace, PnpmCatalogs | undefined> = 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();
Expand All @@ -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;
Expand All @@ -145,6 +204,17 @@ export class DependencyAnalyzer {
}

allVersionForDependency.add(dependencyVersion);

if (catalogName !== undefined) {
let catalogNamesForDependency: Set<string> | undefined =
catalogNamesByPackageName.get(dependencyName);
if (!catalogNamesForDependency) {
catalogNamesForDependency = new Set<string>();
catalogNamesByPackageName.set(dependencyName, catalogNamesForDependency);
}

catalogNamesForDependency.add(catalogName);
}
}
}

Expand Down Expand Up @@ -190,7 +260,8 @@ export class DependencyAnalyzer {
return {
commonVersionsConfiguration,
implicitlyPreferredVersionByPackageName,
allVersionsByPackageName
allVersionsByPackageName,
catalogNamesByPackageName
};
}
}
96 changes: 96 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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)
};
}
12 changes: 10 additions & 2 deletions libraries/rush-lib/src/logic/pnpm/PnpmfileConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
105 changes: 105 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/test/PnpmCatalogReference.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading