Skip to content
Merged
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": "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": "[email protected]"
}
4 changes: 4 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,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.
*
Expand Down
61 changes: 59 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 @@ -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<string, Set<string>>;

/**
* A map of dependency name to the PNPM catalogs it is referenced through, e.g. `default` for `catalog:`.
*/
catalogNamesByPackageName: Map<string, Set<string>>;
}

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

const catalogNamesByPackageName: Map<string, Set<string>> = new Map();

// Catalogs can differ per subspace. Load them lazily, since most projects don't 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 +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;
Expand All @@ -145,6 +190,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 +246,8 @@ export class DependencyAnalyzer {
return {
commonVersionsConfiguration,
implicitlyPreferredVersionByPackageName,
allVersionsByPackageName
allVersionsByPackageName,
catalogNamesByPackageName
};
}
}
72 changes: 72 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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)
};
}
11 changes: 9 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,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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, string>> = { default: { typescript: '~5.8.2' } };
expect(tryGetPnpmCatalogVersion(catalogs, 'constructor', 'name')).toBeUndefined();
expect(tryGetPnpmCatalogVersion(catalogs, 'default', 'toString')).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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`;
Expand Down Expand Up @@ -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'
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "app-a",
"version": "1.0.0",
"dependencies": {
"lib-b": "workspace:*",
"tslib": "catalog:default",
"typescript": "catalog:"
},
"devDependencies": {
"eslint": "catalog:tools"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Loading
Loading