From 48396437d45ae432642ea70a97b12a16daac34d5 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:29:04 -0700 Subject: [PATCH] [rush] Resolve pnpm catalogs in injected cross-subspace deps PNPM resolves `catalog:` only for its own workspace importers. Projects injected from another subspace are installed as external packages, so the subspace pnpmfile shim now replaces their catalog references with entries from their own subspace's catalogs. The injected dependencies hash uses the resolved versions so catalog edits trigger a reinstall. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ogs-in-injected-deps_2026-10-15-05-28.json | 11 +++ libraries/rush-lib/src/api/Subspace.ts | 57 ++++++++++- .../rush-lib/src/api/test/Subspace.test.ts | 55 +++++++++++ .../rush-lib/src/logic/pnpm/IPnpmfile.ts | 6 ++ .../logic/pnpm/SubspaceGlobalPnpmfileShim.ts | 96 +++++++++++++++++-- .../pnpm/SubspacePnpmfileConfiguration.ts | 43 ++++++++- .../test/SubspaceGlobalPnpmfileShim.test.ts | 91 ++++++++++++++++++ .../SubspacePnpmfileConfiguration.test.ts | 46 +++++++++ .../catalog-consumer/package.json | 13 +++ .../catalog-provider/package.json | 19 ++++ .../common/config/rush/experiments.json | 3 + .../common/config/rush/pnpm-config.json | 15 +++ .../common/config/rush/subspaces.json | 5 + .../common/config/rush/version-policies.json | 1 + .../consumer-subspace/common-versions.json | 8 ++ .../consumer-subspace/pnpm-config.json | 10 ++ .../subspaces/default/common-versions.json | 8 ++ .../unrelated-subspace/common-versions.json | 8 ++ .../unrelated-subspace/pnpm-config.json | 9 ++ .../rush.json | 25 +++++ .../transitive-provider/package.json | 7 ++ .../unrelated/package.json | 7 ++ 22 files changed, 530 insertions(+), 13 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-resolve-catalogs-in-injected-deps_2026-10-15-05-28.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/SubspaceGlobalPnpmfileShim.test.ts create mode 100644 libraries/rush-lib/src/logic/pnpm/test/SubspacePnpmfileConfiguration.test.ts create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-consumer/package.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-provider/package.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/experiments.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/subspaces.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/version-policies.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/common-versions.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/default/common-versions.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/common-versions.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/rush.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/transitive-provider/package.json create mode 100644 libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/unrelated/package.json diff --git a/common/changes/@microsoft/rush/bmiddha-resolve-catalogs-in-injected-deps_2026-10-15-05-28.json b/common/changes/@microsoft/rush/bmiddha-resolve-catalogs-in-injected-deps_2026-10-15-05-28.json new file mode 100644 index 00000000000..e86af7239e6 --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-resolve-catalogs-in-injected-deps_2026-10-15-05-28.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Resolve PNPM \"catalog:\" references in projects injected from other subspaces.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/api/Subspace.ts b/libraries/rush-lib/src/api/Subspace.ts index 611f96859ae..766bc8038a4 100644 --- a/libraries/rush-lib/src/api/Subspace.ts +++ b/libraries/rush-lib/src/api/Subspace.ts @@ -15,7 +15,8 @@ import { CommonVersionsConfiguration } from './CommonVersionsConfiguration'; import { RepoStateFile } from '../logic/RepoStateFile'; import type { PnpmPackageManager } from './packageManager/PnpmPackageManager'; import { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration'; -import { getEffectivePnpmCatalogs } from '../logic/pnpm/EffectivePnpmOptions'; +import { getEffectivePnpmCatalogs, type PnpmCatalogs } from '../logic/pnpm/EffectivePnpmOptions'; +import { tryGetPnpmCatalogVersion, tryParsePnpmCatalogName } from '../logic/pnpm/PnpmCatalogReference'; import { SubspacePnpmfileConfiguration } from '../logic/pnpm/SubspacePnpmfileConfiguration'; import type { ISubspacePnpmfileShimSettings } from '../logic/pnpm/IPnpmfile'; @@ -466,6 +467,26 @@ export class Subspace { this.#rushConfiguration.projects.map((rushProject) => rushProject.packageName) ); + const catalogsBySubspaceName: Map = new Map(); + const getOtherSubspaceCatalogs: (rushProject: RushConfigurationProject) => PnpmCatalogs | undefined = ( + rushProject: RushConfigurationProject + ) => { + // PNPM resolves the catalog references of this subspace's own projects + if (this.contains(rushProject)) { + return undefined; + } + + const { subspace } = rushProject; + if (!catalogsBySubspaceName.has(subspace.subspaceName)) { + catalogsBySubspaceName.set( + subspace.subspaceName, + getEffectivePnpmCatalogs(subspace, this.#rushConfiguration) + ); + } + + return catalogsBySubspaceName.get(subspace.subspaceName); + }; + // get all related package.json while (relatedProjects.length > 0) { const rushProject: RushConfigurationProject = relatedProjects.pop()!; @@ -491,12 +512,14 @@ export class Subspace { } } + // Hash the resolved versions so that editing a catalog entry triggers a reinstall + const catalogs: PnpmCatalogs | undefined = getOtherSubspaceCatalogs(rushProject); allPackageJson.push({ name, bin, - dependencies, - peerDependencies, - optionalDependencies, + dependencies: _resolveCatalogReferences(dependencies, catalogs), + peerDependencies: _resolveCatalogReferences(peerDependencies, catalogs), + optionalDependencies: _resolveCatalogReferences(optionalDependencies, catalogs), dependenciesMeta, peerDependenciesMeta, resolutions @@ -517,3 +540,29 @@ export class Subspace { return packageJsonInjectedDependenciesHash; } } + +function _resolveCatalogReferences( + dependencies: Record | undefined, + catalogs: PnpmCatalogs | undefined +): Record | undefined { + if (!dependencies || !catalogs) { + return dependencies; + } + + let result: Record | undefined; + for (const [dependencyName, versionSpecifier] of Object.entries(dependencies)) { + const catalogName: string | undefined = tryParsePnpmCatalogName(versionSpecifier); + if (catalogName !== undefined) { + const catalogVersion: string | undefined = tryGetPnpmCatalogVersion( + catalogs, + catalogName, + dependencyName + ); + if (catalogVersion !== undefined) { + (result ??= { ...dependencies })[dependencyName] = catalogVersion; + } + } + } + + return result ?? dependencies; +} diff --git a/libraries/rush-lib/src/api/test/Subspace.test.ts b/libraries/rush-lib/src/api/test/Subspace.test.ts index 4087b2b9716..ac7fbcf98df 100644 --- a/libraries/rush-lib/src/api/test/Subspace.test.ts +++ b/libraries/rush-lib/src/api/test/Subspace.test.ts @@ -3,6 +3,8 @@ import * as path from 'node:path'; +import type { IPackageJson } from '@rushstack/node-core-library'; + import { RushConfiguration } from '../RushConfiguration'; import { Subspace } from '../Subspace'; @@ -139,5 +141,58 @@ describe(Subspace.name, () => { expect(hashAfter).toBeDefined(); expect(hashBefore).not.toBe(hashAfter); }); + + describe('with catalog references in injected projects from other subspaces', () => { + // "catalog-consumer" (consumer-subspace) injects "catalog-provider" and "transitive-provider" (default) + const injectedSubspaceCatalogsRushJson: string = path.resolve( + __dirname, + '../../logic/pnpm/test/repoWithInjectedSubspaceCatalogs/rush.json' + ); + + it('hashes the catalog versions instead of the catalog references', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + injectedSubspaceCatalogsRushJson + ); + const consumerSubspace: Subspace = rushConfiguration.getSubspace('consumer-subspace'); + + const hashBefore: string | undefined = consumerSubspace.getPackageJsonInjectedDependenciesHash(); + + // The default subspace's catalog versions, which differ from consumer-subspace's catalogs + const providerPackageJson: IPackageJson = + rushConfiguration.getProjectByName('catalog-provider')!.packageJson; + providerPackageJson.dependencies = { + ...providerPackageJson.dependencies, + lodash: '^4.17.21', + semver: '~7.5.4' + }; + providerPackageJson.optionalDependencies = { fsevents: '~2.3.2' }; + providerPackageJson.peerDependencies = { typescript: '~5.3.0' }; + rushConfiguration.getProjectByName('transitive-provider')!.packageJson.dependencies = { + lodash: '^4.17.21' + }; + + const hashAfter: string | undefined = consumerSubspace.getPackageJsonInjectedDependenciesHash(); + + expect(hashBefore).toBeDefined(); + expect(hashAfter).toBe(hashBefore); + }); + + it("changes when an entry in the injected project's subspace catalog changes", () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + injectedSubspaceCatalogsRushJson + ); + const consumerSubspace: Subspace = rushConfiguration.getSubspace('consumer-subspace'); + + const hashBefore: string | undefined = consumerSubspace.getPackageJsonInjectedDependenciesHash(); + + rushConfiguration.pnpmOptions.globalCatalogs!.default.semver = '~7.6.0'; + + const hashAfter: string | undefined = consumerSubspace.getPackageJsonInjectedDependenciesHash(); + + expect(hashBefore).toBeDefined(); + expect(hashAfter).toBeDefined(); + expect(hashAfter).not.toBe(hashBefore); + }); + }); }); }); diff --git a/libraries/rush-lib/src/logic/pnpm/IPnpmfile.ts b/libraries/rush-lib/src/logic/pnpm/IPnpmfile.ts index 50e9ddadd25..b658c35fdcb 100644 --- a/libraries/rush-lib/src/logic/pnpm/IPnpmfile.ts +++ b/libraries/rush-lib/src/logic/pnpm/IPnpmfile.ts @@ -6,6 +6,7 @@ import type { IPackageJson } from '@rushstack/node-core-library'; import type { IPnpmShrinkwrapYaml } from './PnpmShrinkwrapFile'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import type { PnpmCatalogs } from './EffectivePnpmOptions'; /** * The `settings` parameter passed to {@link IPnpmfileShim.hooks.readPackage} and @@ -26,6 +27,7 @@ export interface IWorkspaceProjectInfo extends Pick { packageVersion: RushConfigurationProject['packageJson']['version']; injectedDependencies: Array; + subspaceName?: string; } /** @@ -37,6 +39,10 @@ export interface ISubspacePnpmfileShimSettings { workspaceProjects: Record; subspaceProjects: Record; userPnpmfilePath?: string; + /** + * The effective catalogs of the other subspaces whose projects are injected into this subspace. + */ + catalogsBySubspaceName?: Record; } /** diff --git a/libraries/rush-lib/src/logic/pnpm/SubspaceGlobalPnpmfileShim.ts b/libraries/rush-lib/src/logic/pnpm/SubspaceGlobalPnpmfileShim.ts index 02c143fcb31..29a3040eb74 100644 --- a/libraries/rush-lib/src/logic/pnpm/SubspaceGlobalPnpmfileShim.ts +++ b/libraries/rush-lib/src/logic/pnpm/SubspaceGlobalPnpmfileShim.ts @@ -8,8 +8,8 @@ import path from 'node:path'; -// This file can use "import type" but otherwise should not reference any other modules, since it will -// be run from the "common/temp" directory +// This file runs from the "common/temp" directory, so besides "import type" it may only import local modules +// without runtime dependencies, which webpack bundles into the shim import type * as TSemver from 'semver'; import type { IPackageJson } from '@rushstack/node-core-library'; @@ -22,11 +22,23 @@ import type { IWorkspaceProjectInfo } from './IPnpmfile'; import type { IPnpmShrinkwrapYaml } from './PnpmShrinkwrapFile'; +import type { PnpmCatalogs } from './EffectivePnpmOptions'; +import { tryGetPnpmCatalogVersion, tryParsePnpmCatalogName } from './PnpmCatalogReference'; -let settings: ISubspacePnpmfileShimSettings; +let settings: ISubspacePnpmfileShimSettings | undefined; let userPnpmfile: IPnpmfile | undefined; let semver: typeof TSemver | undefined; +const INSTALLED_PACKAGE_DEPENDENCY_FIELDS: ReadonlyArray< + 'dependencies' | 'optionalDependencies' | 'peerDependencies' +> = ['dependencies', 'optionalDependencies', 'peerDependencies']; + +export function reset(): void { + settings = undefined; + userPnpmfile = undefined; + semver = undefined; +} + // Initialize all external aspects of the pnpmfile shim. When using the shim, settings // are always expected to be available. Init must be called before running any hook that // depends on a resource obtained from or related to the settings, and will require modules @@ -52,18 +64,87 @@ function init(context: IPnpmfileContext | any): IPnpmfileContext { // Reuse the already initialized settings context.splitWorkspacePnpmfileShimSettings = settings; } + const definedSettings: ISubspacePnpmfileShimSettings = settings!; // If a userPnpmfilePath is provided, we expect it to exist - if (!userPnpmfile && settings.userPnpmfilePath) { - userPnpmfile = require(settings.userPnpmfilePath); + if (!userPnpmfile && definedSettings.userPnpmfilePath) { + userPnpmfile = require(definedSettings.userPnpmfilePath); } // If a semverPath is provided, we expect it to exist - if (!semver && settings.semverPath) { - semver = require(settings.semverPath); + if (!semver && definedSettings.semverPath) { + semver = require(definedSettings.semverPath); } // Return the normalized context return context as IPnpmfileContext; } +// PNPM resolves "catalog:" only for its own workspace projects. Projects from other subspaces are installed as +// external "file:" packages, so resolve their references with the catalogs of their own subspace. +function resolveOtherSubspaceCatalogReferences(pkg: IPackageJson): void { + if (!settings) { + throw new Error(`splitWorkspaceGlobalPnpmfileShimSettings not initialized`); + } + + // Rewriting this subspace's own projects would change the specifiers of the lockfile importers + if (!Object.prototype.hasOwnProperty.call(settings.workspaceProjects, pkg.name)) { + return; + } + + const workspaceProject: IWorkspaceProjectInfo = settings.workspaceProjects[pkg.name]; + for (const dependencyField of INSTALLED_PACKAGE_DEPENDENCY_FIELDS) { + const dependencies: Record | undefined = pkg[dependencyField]; + if (!dependencies) { + continue; + } + + for (const [dependencyName, versionSpecifier] of Object.entries(dependencies)) { + const catalogName: string | undefined = tryParsePnpmCatalogName(versionSpecifier); + if (catalogName !== undefined) { + dependencies[dependencyName] = getCatalogVersion( + workspaceProject, + dependencyName, + versionSpecifier, + catalogName + ); + } + } + } +} + +function getCatalogVersion( + workspaceProject: IWorkspaceProjectInfo, + dependencyName: string, + versionSpecifier: string, + catalogName: string +): string { + const { packageName, subspaceName } = workspaceProject; + const subspaceCatalogs: PnpmCatalogs | undefined = + subspaceName !== undefined && + settings?.catalogsBySubspaceName && + Object.prototype.hasOwnProperty.call(settings.catalogsBySubspaceName, subspaceName) + ? settings.catalogsBySubspaceName[subspaceName] + : undefined; + + const catalogVersion: string | undefined = tryGetPnpmCatalogVersion( + subspaceCatalogs, + catalogName, + dependencyName + ); + if (catalogVersion !== undefined) { + return catalogVersion; + } + + const prefix: string = `The "${packageName}" project depends on "${dependencyName}@${versionSpecifier}", but`; + if (!subspaceCatalogs) { + throw new Error(`${prefix} no catalogs were found for the "${subspaceName}" subspace.`); + } else if (!Object.prototype.hasOwnProperty.call(subspaceCatalogs, catalogName)) { + throw new Error(`${prefix} the "${subspaceName}" subspace does not define a "${catalogName}" catalog.`); + } else { + throw new Error( + `${prefix} the "${catalogName}" catalog of the "${subspaceName}" subspace has no "${dependencyName}" entry.` + ); + } +} + // Rewrite rush project referenced in split workspace. // For example: "project-a": "workspace:*" --> "project-a": "link:../../project-a" function rewriteRushProjectVersions( @@ -160,6 +241,7 @@ export const hooks: IPnpmfileHooks = { // Rewrite workspace protocol to link protocol for non split workspace projects readPackage: (pkg: IPackageJson, context: IPnpmfileContext) => { context = init(context); + resolveOtherSubspaceCatalogReferences(pkg); rewriteRushProjectVersions(pkg.name, pkg.dependencies); rewriteRushProjectVersions(pkg.name, pkg.devDependencies); return userPnpmfile?.hooks?.readPackage ? userPnpmfile.hooks.readPackage(pkg, context) : pkg; diff --git a/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts index 17bf18a5a57..dc5c64fd8d1 100644 --- a/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts @@ -13,7 +13,7 @@ import type { PnpmPackageManager } from '../../api/packageManager/PnpmPackageMan import { RushConstants } from '../RushConstants'; import type { Subspace } from '../../api/Subspace'; import type { PnpmOptionsConfiguration } from './PnpmOptionsConfiguration'; -import { getEffectivePnpmOptions } from './EffectivePnpmOptions'; +import { getEffectivePnpmCatalogs, getEffectivePnpmOptions, type PnpmCatalogs } from './EffectivePnpmOptions'; /** * Loads PNPM's pnpmfile.js configuration, and invokes it to preprocess package.json files, @@ -76,7 +76,8 @@ export class SubspacePnpmfileConfiguration { packageName, projectRelativeFolder, packageVersion: packageJson.version, - injectedDependencies: Array.from(projectNameToInjectedDependenciesMap.get(packageName) || []) + injectedDependencies: Array.from(projectNameToInjectedDependenciesMap.get(packageName) || []), + subspaceName: project.subspace.subspaceName }; (subspace.contains(project) ? subspaceProjects : workspaceProjects)[packageName] = workspaceProjectInfo; } @@ -87,6 +88,15 @@ export class SubspacePnpmfileConfiguration { semverPath: Import.resolveModule({ modulePath: 'semver', baseFolderPath: __dirname }) }; + const catalogsBySubspaceName: Record | undefined = _getInjectedProjectCatalogs( + rushConfiguration, + subspace, + projectNameToInjectedDependenciesMap + ); + if (catalogsBySubspaceName) { + settings.catalogsBySubspaceName = catalogsBySubspaceName; + } + // common/config/subspaces//.pnpmfile.cjs const userPnpmfilePath: string = path.join( subspace.getVariantDependentSubspaceConfigFolderPath(variant), @@ -100,6 +110,35 @@ export class SubspacePnpmfileConfiguration { } } +function _getInjectedProjectCatalogs( + rushConfiguration: RushConfiguration, + subspace: Subspace, + projectNameToInjectedDependenciesMap: Map> +): Record | undefined { + let result: Record | undefined; + const visitedSubspaceNames: Set = new Set([subspace.subspaceName]); + for (const injectedDependencies of projectNameToInjectedDependenciesMap.values()) { + for (const injectedDependencyName of injectedDependencies) { + const injectedProject: RushConfigurationProject | undefined = + rushConfiguration.getProjectByName(injectedDependencyName); + if (!injectedProject || visitedSubspaceNames.has(injectedProject.subspace.subspaceName)) { + continue; + } + + visitedSubspaceNames.add(injectedProject.subspace.subspaceName); + const catalogs: PnpmCatalogs | undefined = getEffectivePnpmCatalogs( + injectedProject.subspace, + rushConfiguration + ); + if (catalogs) { + (result ??= {})[injectedProject.subspace.subspaceName] = catalogs; + } + } + } + + return result; +} + function _getProjectNameToInjectedDependenciesMap( rushConfiguration: RushConfiguration, subspace: Subspace diff --git a/libraries/rush-lib/src/logic/pnpm/test/SubspaceGlobalPnpmfileShim.test.ts b/libraries/rush-lib/src/logic/pnpm/test/SubspaceGlobalPnpmfileShim.test.ts new file mode 100644 index 00000000000..fa2110039ce --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/SubspaceGlobalPnpmfileShim.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IPackageJson } from '@rushstack/node-core-library'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import type { IPnpmfileContext, ISubspacePnpmfileShimSettings } from '../IPnpmfile'; +import { hooks, reset } from '../SubspaceGlobalPnpmfileShim'; +import { SubspacePnpmfileConfiguration } from '../SubspacePnpmfileConfiguration'; + +// "catalog-consumer" (consumer-subspace) injects "catalog-provider" (default). Each subspace has its own catalogs. +const INJECTED_SUBSPACE_CATALOGS_RUSH_JSON: string = `${__dirname}/repoWithInjectedSubspaceCatalogs/rush.json`; + +describe('SubspaceGlobalPnpmfileShim', () => { + let rushConfiguration: RushConfiguration; + let settings: ISubspacePnpmfileShimSettings; + + function readPackage(pkg: IPackageJson): IPackageJson { + const context: IPnpmfileContext = { + log: () => {}, + splitWorkspacePnpmfileShimSettings: settings + } as IPnpmfileContext; + return hooks.readPackage!(pkg, context); + } + + function getPackageJson(packageName: string): IPackageJson { + return JSON.parse(JSON.stringify(rushConfiguration.getProjectByName(packageName)!.packageJson)); + } + + beforeEach(() => { + reset(); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(INJECTED_SUBSPACE_CATALOGS_RUSH_JSON); + settings = SubspacePnpmfileConfiguration.getSubspacePnpmfileShimSettings( + rushConfiguration, + rushConfiguration.getSubspace('consumer-subspace'), + undefined + ); + }); + + afterEach(() => { + reset(); + }); + + describe('readPackage', () => { + it("resolves catalog references of an injected project with its own subspace's catalogs", () => { + const pkg: IPackageJson = readPackage(getPackageJson('catalog-provider')); + + // From the default subspace's catalogs, not consumer-subspace's + expect(pkg.dependencies).toEqual({ + 'left-pad': '^1.3.0', + lodash: '^4.17.21', + semver: '~7.5.4', + 'transitive-provider': 'file:../transitive-provider' + }); + expect(pkg.optionalDependencies).toEqual({ fsevents: '~2.3.2' }); + expect(pkg.peerDependencies).toEqual({ typescript: '~5.3.0' }); + // PNPM does not install the devDependencies of an external package + expect(pkg.devDependencies).toEqual({ jest: 'catalog:' }); + }); + + it("does not change the catalog references of the subspace's own projects", () => { + const pkg: IPackageJson = readPackage(getPackageJson('catalog-consumer')); + expect(pkg.dependencies).toEqual({ + lodash: 'catalog:', + 'catalog-provider': 'file:../catalog-provider' + }); + }); + + it('reports an error when the catalog does not have an entry for the dependency', () => { + const pkg: IPackageJson = getPackageJson('catalog-provider'); + pkg.dependencies!['missing-package'] = 'catalog:'; + expect(() => readPackage(pkg)).toThrow( + 'The "catalog-provider" project depends on "missing-package@catalog:", but the "default" catalog of the "default" subspace has no "missing-package" entry.' + ); + }); + + it('reports an error when the catalog does not exist', () => { + const pkg: IPackageJson = getPackageJson('catalog-provider'); + pkg.optionalDependencies!.fsevents = 'catalog:missing-catalog'; + expect(() => readPackage(pkg)).toThrow( + 'The "catalog-provider" project depends on "fsevents@catalog:missing-catalog", but the "default" subspace does not define a "missing-catalog" catalog.' + ); + }); + + it('reports an error when no catalogs are available for the subspace of the project', () => { + expect(() => readPackage(getPackageJson('unrelated'))).toThrow( + 'The "unrelated" project depends on "lodash@catalog:", but no catalogs were found for the "unrelated-subspace" subspace.' + ); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/pnpm/test/SubspacePnpmfileConfiguration.test.ts b/libraries/rush-lib/src/logic/pnpm/test/SubspacePnpmfileConfiguration.test.ts new file mode 100644 index 00000000000..7706c43c690 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/SubspacePnpmfileConfiguration.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import type { ISubspacePnpmfileShimSettings } from '../IPnpmfile'; +import { SubspacePnpmfileConfiguration } from '../SubspacePnpmfileConfiguration'; + +// "catalog-consumer" (consumer-subspace) injects "catalog-provider" (default). Each subspace has its own catalogs. +const INJECTED_SUBSPACE_CATALOGS_RUSH_JSON: string = `${__dirname}/repoWithInjectedSubspaceCatalogs/rush.json`; + +describe(SubspacePnpmfileConfiguration.name, () => { + describe(SubspacePnpmfileConfiguration.getSubspacePnpmfileShimSettings.name, () => { + it('includes the catalogs of the subspaces of injected projects from other subspaces', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + INJECTED_SUBSPACE_CATALOGS_RUSH_JSON + ); + + const settings: ISubspacePnpmfileShimSettings = + SubspacePnpmfileConfiguration.getSubspacePnpmfileShimSettings( + rushConfiguration, + rushConfiguration.getSubspace('consumer-subspace'), + undefined + ); + + // Not consumer-subspace's own catalogs, nor those of unrelated-subspace, which has no injected projects + expect(settings.catalogsBySubspaceName).toEqual({ + default: rushConfiguration.pnpmOptions.globalCatalogs + }); + }); + + it('does not include catalogs for injected projects from the same subspace', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${__dirname}/../../../api/test/repoInjectedDeps/rush.json` + ); + + const settings: ISubspacePnpmfileShimSettings = + SubspacePnpmfileConfiguration.getSubspacePnpmfileShimSettings( + rushConfiguration, + rushConfiguration.defaultSubspace, + undefined + ); + expect(settings.subspaceProjects.consumer.injectedDependencies).toEqual(['provider']); + expect(settings).not.toHaveProperty('catalogsBySubspaceName'); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-consumer/package.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-consumer/package.json new file mode 100644 index 00000000000..b89876da931 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-consumer/package.json @@ -0,0 +1,13 @@ +{ + "name": "catalog-consumer", + "version": "1.0.0", + "dependencies": { + "lodash": "catalog:", + "catalog-provider": "workspace:*" + }, + "dependenciesMeta": { + "catalog-provider": { + "injected": true + } + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-provider/package.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-provider/package.json new file mode 100644 index 00000000000..6e167affc61 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/catalog-provider/package.json @@ -0,0 +1,19 @@ +{ + "name": "catalog-provider", + "version": "1.0.0", + "dependencies": { + "left-pad": "^1.3.0", + "lodash": "catalog:default", + "semver": "catalog:", + "transitive-provider": "workspace:*" + }, + "optionalDependencies": { + "fsevents": "catalog:" + }, + "peerDependencies": { + "typescript": "catalog:tools" + }, + "devDependencies": { + "jest": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/experiments.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/experiments.json new file mode 100644 index 00000000000..a20c6d24388 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "exemptDecoupledDependenciesBetweenSubspaces": true +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..adc750f925e --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/pnpm-config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "useWorkspaces": true, + "globalCatalogs": { + "default": { + "fsevents": "~2.3.2", + "jest": "^29.0.0", + "lodash": "^4.17.21", + "semver": "~7.5.4" + }, + "tools": { + "typescript": "~5.3.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/subspaces.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/subspaces.json new file mode 100644 index 00000000000..e95203d5d1b --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/subspaces.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json", + "subspacesEnabled": true, + "subspaceNames": ["consumer-subspace", "unrelated-subspace"] +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/version-policies.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/version-policies.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/rush/version-policies.json @@ -0,0 +1 @@ +[] diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/common-versions.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/pnpm-config.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/pnpm-config.json new file mode 100644 index 00000000000..b6d29fc30af --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/consumer-subspace/pnpm-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "useWorkspaces": true, + "globalCatalogs": { + "default": { + "lodash": "^3.10.1", + "semver": "^6.0.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/default/common-versions.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/default/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/default/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/common-versions.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/pnpm-config.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/pnpm-config.json new file mode 100644 index 00000000000..07cec4c4da0 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/common/config/subspaces/unrelated-subspace/pnpm-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "useWorkspaces": true, + "globalCatalogs": { + "default": { + "lodash": "^2.4.2" + } + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/rush.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/rush.json new file mode 100644 index 00000000000..a29b8cc2a9c --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/rush.json @@ -0,0 +1,25 @@ +{ + "rushVersion": "1.0.5", + "pnpmVersion": "9.15.0", + + "projects": [ + { + "packageName": "catalog-provider", + "projectFolder": "catalog-provider" + }, + { + "packageName": "transitive-provider", + "projectFolder": "transitive-provider" + }, + { + "packageName": "catalog-consumer", + "projectFolder": "catalog-consumer", + "subspaceName": "consumer-subspace" + }, + { + "packageName": "unrelated", + "projectFolder": "unrelated", + "subspaceName": "unrelated-subspace" + } + ] +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/transitive-provider/package.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/transitive-provider/package.json new file mode 100644 index 00000000000..241ac6813bd --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/transitive-provider/package.json @@ -0,0 +1,7 @@ +{ + "name": "transitive-provider", + "version": "1.0.0", + "dependencies": { + "lodash": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/unrelated/package.json b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/unrelated/package.json new file mode 100644 index 00000000000..2e8ae5e1a41 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/repoWithInjectedSubspaceCatalogs/unrelated/package.json @@ -0,0 +1,7 @@ +{ + "name": "unrelated", + "version": "1.0.0", + "dependencies": { + "lodash": "catalog:" + } +}