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": "Make version consistency checks (\"rush check\", \"ensureConsistentVersions\", and \"--make-consistent\") aware of PNPM catalog references. A \"catalog:\" or \"catalog:<name>\" specifier is now compared using the version that it resolves to in the catalogs of the project's subspace, so it is no longer reported as inconsistent with the same literal version or with \"preferredVersions\". \"rush check\" annotates versions that come from a catalog, and \"rush check --json\" adds an optional \"catalogReferences\" field. \"--make-consistent\" no longer replaces catalog references in other projects with literal versions.",
"type": "patch"
}
],
"packageName": "@microsoft/rush",
"email": "223556219+Copilot@users.noreply.github.com"
}
278 changes: 277 additions & 1 deletion 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 { DependencyType, 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,273 @@ 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>,
pnpmCatalogs: PnpmCatalogs | undefined = CATALOGS
): VersionMismatchFinderProject {
return new VersionMismatchFinderProject(
{
packageName,
packageJsonEditor: PackageJsonEditor.fromObject({ dependencies } as any, 'foo.json'),
decoupledLocalDependencies: new Set<string>()
} as any as RushConfigurationProject,
pnpmCatalogs
);
}

it('finds no mismatch between a catalog reference and the same version', () => {
const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' });
const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: '~5.8.2' });

const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]);
expect(mismatchFinder.numberOfMismatches).toEqual(0);
});

it('treats "catalog:" and "catalog:default" as the same reference', () => {
const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' });
const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: 'catalog:default' });

const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]);
expect(mismatchFinder.numberOfMismatches).toEqual(0);
});

it('finds a mismatch between a catalog reference and a different version', () => {
const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' });
const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: '~5.7.0' });

const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]);
expect(mismatchFinder.getMismatches()).toEqual(['typescript']);
expect(mismatchFinder.getVersionsOfMismatch('typescript')).toEqual(['~5.8.2', '~5.7.0']);
expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.8.2')).toEqual([projectA]);
expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.7.0')).toEqual([projectB]);
expect(mismatchFinder.tryGetCatalogVersionSpecifier('typescript', '~5.8.2', projectA)).toEqual(
'catalog:'
);
expect(mismatchFinder.tryGetCatalogVersionSpecifier('typescript', '~5.7.0', projectB)).toBeUndefined();
});

it('compares a catalog reference with the preferred versions', async () => {
const commonVersionsConfiguration: CommonVersionsConfiguration =
await CommonVersionsConfiguration.loadFromFileAsync(`${__dirname}/jsonFiles/common-versions.json`);
const commonVersions: VersionMismatchFinderEntity = new VersionMismatchFinderCommonVersions(
commonVersionsConfiguration
);
const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' });

commonVersionsConfiguration.preferredVersions.set('typescript', '~5.8.2');
expect(new VersionMismatchFinder([commonVersions, projectA]).numberOfMismatches).toEqual(0);

commonVersionsConfiguration.preferredVersions.set('typescript', '~5.7.0');
const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([commonVersions, projectA]);
expect(mismatchFinder.getMismatches()).toEqual(['typescript']);
expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.7.0')).toEqual([commonVersions]);
expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.8.2')).toEqual([projectA]);
});

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);
}
});

it("resolves each project's catalog references with that project's catalogs", () => {
const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' });
const projectB: VersionMismatchFinderEntity = createProject(
'B',
{ typescript: 'catalog:' },
{ default: { typescript: '~5.7.0' } }
);

const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([projectA, projectB]);
expect(mismatchFinder.getVersionsOfMismatch('typescript')).toEqual(['~5.8.2', '~5.7.0']);
expect(mismatchFinder.getConsumersOfMismatch('typescript', '~5.7.0')).toEqual([projectB]);
});

it('reports a catalog reference that does not resolve as its own version', () => {
const projectA: VersionMismatchFinderEntity = createProject('A', { typescript: 'catalog:' });
const projectB: VersionMismatchFinderEntity = createProject('B', { typescript: 'catalog:missing' });
const projectC: VersionMismatchFinderEntity = createProject('C', { lodash: 'catalog:' });
const projectD: VersionMismatchFinderEntity = createProject('D', { lodash: 'catalog:default' });
const projectE: VersionMismatchFinderEntity = createProject('E', { lodash: '^4.17.21' }, undefined);
const projectF: VersionMismatchFinderEntity = createProject('F', { lodash: 'catalog:' }, undefined);

const mismatchFinder: VersionMismatchFinder = new VersionMismatchFinder([
projectA,
projectB,
projectC,
projectD,
projectE,
projectF
]);
expect(mismatchFinder.getMismatches()).toEqual(['typescript', 'lodash']);
expect(mismatchFinder.getVersionsOfMismatch('typescript')).toEqual(['~5.8.2', 'catalog:missing']);
expect(mismatchFinder.getConsumersOfMismatch('typescript', 'catalog:missing')).toEqual([projectB]);
expect(mismatchFinder.getVersionsOfMismatch('lodash')).toEqual(['catalog:', '^4.17.21']);
expect(mismatchFinder.getConsumersOfMismatch('lodash', 'catalog:')).toEqual([
projectC,
projectD,
projectF
]);
expect(mismatchFinder.tryGetCatalogVersionSpecifier('lodash', 'catalog:', projectD)).toEqual(
'catalog:default'
);
});

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 versions that come from a catalog', () => {
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, () => {
function loadRushConfiguration(repoName: string): RushConfiguration {
return RushConfiguration.loadFromConfigurationFile(
path.resolve(__dirname, '../../logic/test', repoName, 'rush.json')
);
}

it('resolves catalog references with the catalogs of the subspace', () => {
const rushConfiguration: RushConfiguration = loadRushConfiguration('repoWithCatalogs');
expect(VersionMismatchFinder.getMismatches(rushConfiguration).numberOfMismatches).toEqual(0);

rushConfiguration
.getProjectByName('c')!
.packageJsonEditor.addOrUpdateDependency('semver', '^7.0.0', DependencyType.Regular);
rushConfiguration
.getProjectByName('c')!
.packageJsonEditor.addOrUpdateDependency('typescript', '~5.3.0', DependencyType.Dev);

const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches(rushConfiguration);
expect(mismatchFinder.getMismatches()).toEqual(['semver']);
expect(
mismatchFinder.getConsumersOfMismatch('semver', '^7.5.4')!.map(({ friendlyName }) => friendlyName)
).toEqual(['a', 'd']);
});

it.each<[string, string, string, number]>([
['default', 'react', '^18.0.0', 0],
['other-subspace', 'foo', '~2.0.0', 0],
['own-config-subspace', 'react', '^17.0.0', 0],
['own-config-subspace', 'react', '^18.0.0', 1]
])(
'in subspace %s, compares %s with the preferred version %s',
(subspaceName: string, packageName: string, preferredVersion: string, expectedMismatches: number) => {
const rushConfiguration: RushConfiguration = loadRushConfiguration(
'repoWithSubspacesCommonCatalogs'
);
const subspace: Subspace = rushConfiguration.getSubspace(subspaceName);
subspace.getCommonVersions().preferredVersions.set(packageName, preferredVersion);

const mismatchFinder: VersionMismatchFinder = VersionMismatchFinder.getMismatches(
rushConfiguration,
{ subspace, variant: undefined }
);
expect(mismatchFinder.numberOfMismatches).toEqual(expectedMismatches);
if (expectedMismatches > 0) {
// own-config-subspace uses its own catalogs instead of the common catalogs
expect(mismatchFinder.getVersionsOfMismatch(packageName)).toEqual([preferredVersion, '^17.0.0']);
}
}
);
});
});
});
Loading
Loading