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": "Make version consistency checks compare PNPM catalog references (such as \"catalog:\") by the version they resolve to, and stop \"--make-consistent\" from replacing them with literal versions.",
"type": "patch"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
164 changes: 164 additions & 0 deletions libraries/rush-lib/src/api/test/VersionMismatchFinder.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import { AnsiEscape } from '@rushstack/terminal';

import type { RushConfigurationProject } from '../RushConfigurationProject';
import { VersionMismatchFinder } from '../../logic/versionMismatch/VersionMismatchFinder';
import { PackageJsonEditor } from '../PackageJsonEditor';
import { CommonVersionsConfiguration } from '../CommonVersionsConfiguration';
import type { VersionMismatchFinderEntity } from '../../logic/versionMismatch/VersionMismatchFinderEntity';
import { VersionMismatchFinderProject } from '../../logic/versionMismatch/VersionMismatchFinderProject';
import { VersionMismatchFinderCommonVersions } from '../../logic/versionMismatch/VersionMismatchFinderCommonVersions';
import type { PnpmCatalogs } from '../../logic/pnpm/EffectivePnpmOptions';
import { RushConfiguration } from '../RushConfiguration';
import type { Subspace } from '../Subspace';

/* eslint-disable @typescript-eslint/no-explicit-any */
describe(VersionMismatchFinder.name, () => {
Expand Down Expand Up @@ -443,4 +450,161 @@ describe(VersionMismatchFinder.name, () => {
expect(mismatchFinder.getConsumersOfMismatch('@scope/library-1', '~3.2.1')).toEqual([projectB]);
expect(mismatchFinder.getConsumersOfMismatch('@scope/library-1', '1.2.3')).toEqual([projectA]);
});

describe('PNPM catalog references', () => {
const CATALOGS: PnpmCatalogs = {
default: {
typescript: '~5.8.2'
},
react17: {
react: '^17.0.2'
}
};

function createProject(
packageName: string,
dependencies: Record<string, string>
): VersionMismatchFinderProject {
return new VersionMismatchFinderProject(
{
packageName,
packageJsonEditor: PackageJsonEditor.fromObject({ dependencies } as any, 'foo.json'),
decoupledLocalDependencies: new Set<string>()
} as any as RushConfigurationProject,
CATALOGS
);
}

it('compares catalog references by their resolved version', () => {
const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([
createProject('A', { typescript: 'catalog:' }),
createProject('B', { typescript: 'catalog:default' }),
createProject('C', { typescript: '~5.8.2' })
]);
expect(mismatchFinder.numberOfMismatches).toEqual(0);
});

it('resolves named catalogs and allows alternative versions of the resolved version', () => {
const projectA: VersionMismatchFinderEntity = createProject('A', { react: 'catalog:react17' });
const projectB: VersionMismatchFinderEntity = createProject('B', { react: '^18.2.0' });

const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]);
expect(mismatchFinder.getVersionsOfMismatch('react')).toEqual(['^17.0.2', '^18.2.0']);

for (const allowedAlternativeVersion of ['^17.0.2', 'catalog:react17']) {
const alternatives: Map<string, ReadonlyArray<string>> = new Map([
['react', [allowedAlternativeVersion]]
]);
expect(new VersionMismatchFinder([projectA, projectB], alternatives).numberOfMismatches).toEqual(0);
}
});

describe('reporting', () => {
function createMismatchFinder(): VersionMismatchFinder {
return new VersionMismatchFinder([
createProject('A', { typescript: 'catalog:', lodash: 'catalog:' }),
createProject('B', { typescript: 'catalog:default' }),
createProject('C', { typescript: '~5.7.0', lodash: '^4.17.21' }),
createProject('D', { typescript: '~5.8.2', lodash: 'catalog:default' })
]);
}

function captureConsoleLog(callback: () => void): string[] {
const lines: string[] = [];
const consoleLogSpy: jest.SpyInstance = jest
.spyOn(console, 'log')
.mockImplementation((message: string = '') => {
lines.push(AnsiEscape.removeCodes(message));
});
try {
callback();
} finally {
consoleLogSpy.mockRestore();
}

return lines;
}

it('annotates resolved and unresolved catalog versions', () => {
const lines: string[] = captureConsoleLog(() => createMismatchFinder().print());
expect(lines).toEqual([
'lodash',
' catalog: (unresolved PNPM catalog reference)',
' - A',
' - D (catalog:default)',
' ^4.17.21',
' - C',
'',
'typescript',
' ~5.8.2 (catalog:)',
' - A (catalog:)',
' - B (catalog:default)',
' - D',
' ~5.7.0',
' - C',
''
]);
});

it('reports the resolved version and the catalog references as JSON', () => {
const lines: string[] = captureConsoleLog(() => createMismatchFinder().printAsJson());
expect(JSON.parse(lines.join('\n'))).toEqual({
mismatchedVersions: [
{
dependencyName: 'lodash',
versions: [
{
version: 'catalog:',
projects: ['A', 'D'],
catalogReferences: [
{ project: 'A', versionSpecifier: 'catalog:' },
{ project: 'D', versionSpecifier: 'catalog:default' }
]
},
{ version: '^4.17.21', projects: ['C'] }
]
},
{
dependencyName: 'typescript',
versions: [
{
version: '~5.8.2',
projects: ['A', 'B', 'D'],
catalogReferences: [
{ project: 'A', versionSpecifier: 'catalog:' },
{ project: 'B', versionSpecifier: 'catalog:default' }
]
},
{ version: '~5.7.0', projects: ['C'] }
]
}
]
});
});
});

describe(VersionMismatchFinder.getMismatches.name, () => {
it.each([
// Uses the common catalogs
['default', '^18.0.0'],
// Uses its own catalogs instead of the common catalogs
['own-config-subspace', '^17.0.0']
])(
'resolves catalog references with the catalogs of subspace %s',
(subspaceName: string, preferredVersion: string) => {
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
path.resolve(__dirname, '../../logic/test/repoWithSubspacesCommonCatalogs/rush.json')
);
const subspace: Subspace = rushConfiguration.getSubspace(subspaceName);
subspace.getCommonVersions().preferredVersions.set('react', preferredVersion);

const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches(
rushConfiguration,
{ subspace, variant: undefined }
);
expect(mismatchFinder.numberOfMismatches).toEqual(0);
}
);
});
});
});
67 changes: 57 additions & 10 deletions libraries/rush-lib/src/logic/PackageJsonUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from './PackageJsonUpdaterTypes';
import type { Subspace } from '../api/Subspace';
import { MAKE_CONSISTENT_FLAG_NAME } from '../cli/actions/AddAction';
import { tryParsePnpmCatalogName } from './pnpm/PnpmCatalogReference';

/**
* Options for adding a dependency to a particular project.
Expand Down Expand Up @@ -103,6 +104,7 @@ export class PackageJsonUpdater {
readonly #terminal: ITerminal;
readonly #rushConfiguration: RushConfiguration;
readonly #rushGlobalFolder: RushGlobalFolder;
readonly #reportedCatalogReferences: Set<string>;

public constructor(
terminal: ITerminal,
Expand All @@ -112,6 +114,7 @@ export class PackageJsonUpdater {
this.#terminal = terminal;
this.#rushConfiguration = rushConfiguration;
this.#rushGlobalFolder = rushGlobalFolder;
this.#reportedCatalogReferences = new Set();
}

/**
Expand Down Expand Up @@ -330,9 +333,7 @@ export class PackageJsonUpdater {
/**
* Adds a dependency to a particular project. The core business logic for "rush add".
*/
async #doRushAddAsync(
options: IPackageJsonUpdaterRushAddOptions
): Promise<IUpdateProjectOptions[]> {
async #doRushAddAsync(options: IPackageJsonUpdaterRushAddOptions): Promise<IUpdateProjectOptions[]> {
const { projects } = options;

const { DependencyAnalyzer } = await import(
Expand Down Expand Up @@ -462,8 +463,26 @@ export class PackageJsonUpdater {
const projectsByVersion: ReadonlyMap<string, Iterable<VersionMismatchFinderEntity>> | undefined =
mismatches.get(packageName);
if (projectsByVersion) {
for (const consumers of projectsByVersion.values()) {
for (const [existingVersion, consumers] of projectsByVersion) {
for (const consumer of consumers) {
// Replacing a catalog reference with a literal version would detach the project from the catalog
const catalogVersionSpecifier: string | undefined = this.#tryGetCatalogVersionSpecifierToUpdate(
consumer,
packageName
);
if (catalogVersionSpecifier !== undefined) {
if (existingVersion !== version) {
this.#reportCatalogReferenceNotUpdated(
consumer,
packageName,
catalogVersionSpecifier,
existingVersion
);
}

continue;
}

result.push({
project: consumer,
dependenciesToAddOrUpdateOrRemove: {
Expand All @@ -478,12 +497,42 @@ export class PackageJsonUpdater {
return result;
}

#tryGetCatalogVersionSpecifierToUpdate(
project: VersionMismatchFinderEntity,
packageName: string
): string | undefined {
// Matches the dependency that updateProject() updates when it is not given a dependency type
const versionSpecifier: string | undefined = (
project.tryGetDevDependency(packageName) ?? project.tryGetDependency(packageName)
)?.version;
return versionSpecifier !== undefined && tryParsePnpmCatalogName(versionSpecifier) !== undefined
? versionSpecifier
: undefined;
}

#reportCatalogReferenceNotUpdated(
project: VersionMismatchFinderEntity,
packageName: string,
catalogVersionSpecifier: string,
catalogVersion: string
): void {
const key: string = `${project.friendlyName}\0${packageName}`;
if (this.#reportedCatalogReferences.has(key)) {
return;
}

this.#reportedCatalogReferences.add(key);
this.#terminal.writeWarningLine(
`Not updating "${packageName}" in "${project.friendlyName}" because it uses the PNPM catalog ` +
`reference "${catalogVersionSpecifier}" (${catalogVersion}). Rush does not replace catalog ` +
`references when making versions consistent; update the catalog in pnpm-config.json instead.`
);
}

/**
* Remove a dependency from a particular project. The core business logic for "rush remove".
*/
async #doRushRemoveAsync(
options: IPackageJsonUpdaterRushRemoveOptions
): Promise<IRemoveProjectOptions[]> {
async #doRushRemoveAsync(options: IPackageJsonUpdaterRushRemoveOptions): Promise<IRemoveProjectOptions[]> {
const { projects, packagesToUpdate } = options;

this.#terminal.writeLine();
Expand Down Expand Up @@ -803,9 +852,7 @@ export class PackageJsonUpdater {
return normalizedVersion;
}

#collectAllDownstreamDependencies(
project: RushConfigurationProject
): Set<RushConfigurationProject> {
#collectAllDownstreamDependencies(project: RushConfigurationProject): Set<RushConfigurationProject> {
const allProjectDownstreamDependencies: Set<RushConfigurationProject> =
new Set<RushConfigurationProject>();

Expand Down
8 changes: 3 additions & 5 deletions libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ import type { RushConfigurationProject } from '../api/RushConfigurationProject';
import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile';
import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile';
import { Git } from './Git';
import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier';
import type { IPnpmOptionsJson } from './pnpm/PnpmOptionsConfiguration';
import { tryParsePnpmCatalogName } from './pnpm/PnpmCatalogReference';
import {
getCommonPnpmConfigFilePath,
getEffectivePnpmOptions,
Expand Down Expand Up @@ -754,10 +754,8 @@ export class ProjectChangeAnalyzer {
);

for (const [depName, depVersion] of allDependencies) {
const specifier: DependencySpecifier = DependencySpecifier.parseWithCache(depName, depVersion);
if (specifier.specifierType === DependencySpecifierType.Catalog) {
// versionSpecifier holds the catalog name (empty string for "catalog:")
const catalogName: string = specifier.versionSpecifier || 'default';
const catalogName: string | undefined = tryParsePnpmCatalogName(depVersion);
if (catalogName !== undefined) {
const changedPkgs: Set<string> | undefined = changedCatalogPackages.get(catalogName);
if (changedPkgs?.has(depName)) {
changedProjects.add(project);
Expand Down
9 changes: 9 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/PnpmCatalogReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export function tryParsePnpmCatalogName(versionSpecifier: string): string | unde
return catalogName || DEFAULT_PNPM_CATALOG_NAME;
}

/**
* Returns `catalog:` for the default catalog, otherwise `catalog:<catalogName>`.
*/
export function formatPnpmCatalogSpecifier(catalogName: string): string {
return catalogName === DEFAULT_PNPM_CATALOG_NAME
? PNPM_CATALOG_PROTOCOL
: `${PNPM_CATALOG_PROTOCOL}${catalogName}`;
}

export function tryGetPnpmCatalogVersion(
catalogs: PnpmCatalogs | undefined,
catalogName: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {
DEFAULT_PNPM_CATALOG_NAME,
formatPnpmCatalogSpecifier,
tryGetPnpmCatalogVersion,
tryParsePnpmCatalogName
} from '../PnpmCatalogReference';
Expand All @@ -23,6 +24,13 @@ describe('PnpmCatalogReference', () => {
});
});

describe(formatPnpmCatalogSpecifier.name, () => {
it('formats the canonical catalog reference', () => {
expect(formatPnpmCatalogSpecifier(DEFAULT_PNPM_CATALOG_NAME)).toBe('catalog:');
expect(formatPnpmCatalogSpecifier('tools')).toBe('catalog:tools');
});
});

describe(tryGetPnpmCatalogVersion.name, () => {
it('does not resolve Object.prototype members', () => {
const catalogs: Record<string, Record<string, string>> = { default: { typescript: '~5.8.2' } };
Expand Down
Loading
Loading