Skip to content
Open
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 in projects injected from other subspaces.",
"type": "patch"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
57 changes: 53 additions & 4 deletions libraries/rush-lib/src/api/Subspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -466,6 +467,26 @@ export class Subspace {
this.#rushConfiguration.projects.map((rushProject) => rushProject.packageName)
);

const catalogsBySubspaceName: Map<string, PnpmCatalogs | undefined> = 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()!;
Expand All @@ -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
Expand All @@ -517,3 +540,29 @@ export class Subspace {
return packageJsonInjectedDependenciesHash;
}
}

function _resolveCatalogReferences(
dependencies: Record<string, string> | undefined,
catalogs: PnpmCatalogs | undefined
): Record<string, string> | undefined {
if (!dependencies || !catalogs) {
return dependencies;
}

let result: Record<string, string> | 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;
}
55 changes: 55 additions & 0 deletions libraries/rush-lib/src/api/test/Subspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
});
});
});
});
6 changes: 6 additions & 0 deletions libraries/rush-lib/src/logic/pnpm/IPnpmfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +27,7 @@ export interface IWorkspaceProjectInfo
extends Pick<RushConfigurationProject, 'packageName' | 'projectRelativeFolder'> {
packageVersion: RushConfigurationProject['packageJson']['version'];
injectedDependencies: Array<string>;
subspaceName?: string;
}

/**
Expand All @@ -37,6 +39,10 @@ export interface ISubspacePnpmfileShimSettings {
workspaceProjects: Record<string, IWorkspaceProjectInfo>;
subspaceProjects: Record<string, IWorkspaceProjectInfo>;
userPnpmfilePath?: string;
/**
* The effective catalogs of the other subspaces whose projects are injected into this subspace.
*/
catalogsBySubspaceName?: Record<string, PnpmCatalogs>;
}

/**
Expand Down
96 changes: 89 additions & 7 deletions libraries/rush-lib/src/logic/pnpm/SubspaceGlobalPnpmfileShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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<string, string> | 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(
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -87,6 +88,15 @@ export class SubspacePnpmfileConfiguration {
semverPath: Import.resolveModule({ modulePath: 'semver', baseFolderPath: __dirname })
};

const catalogsBySubspaceName: Record<string, PnpmCatalogs> | undefined = _getInjectedProjectCatalogs(
rushConfiguration,
subspace,
projectNameToInjectedDependenciesMap
);
if (catalogsBySubspaceName) {
settings.catalogsBySubspaceName = catalogsBySubspaceName;
}

// common/config/subspaces/<subspace_name>/.pnpmfile.cjs
const userPnpmfilePath: string = path.join(
subspace.getVariantDependentSubspaceConfigFolderPath(variant),
Expand All @@ -100,6 +110,35 @@ export class SubspacePnpmfileConfiguration {
}
}

function _getInjectedProjectCatalogs(
rushConfiguration: RushConfiguration,
subspace: Subspace,
projectNameToInjectedDependenciesMap: Map<string, Set<string>>
): Record<string, PnpmCatalogs> | undefined {
let result: Record<string, PnpmCatalogs> | undefined;
const visitedSubspaceNames: Set<string> = 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
Expand Down
Loading
Loading