Skip to content

Commit 4c8fd4d

Browse files
bmiddhaCopilot
andauthored
rush-resolver-cache-plugin: add pnpm 10 / lockfile v9 compatibility (#5749)
* rush-resolver-cache-plugin: add pnpm 9/10 compatibility - Add IPnpmVersionHelpers interface with version-specific implementations for dep-path hashing, lockfile key format, and store index paths - Vendor pnpm depPathToFilename from exact source commits for v8, v9, v10 - Organize helpers into pnpm/ subdirectory with shared modules for keys (v6/v9), store (v3/v10), depPath (v8/v9/v10), and hash functions - Detect pnpm major version from rush.json config or lockfile format - Add v9 lockfile test fixture and integration tests for pnpm 9 and 10 - Add unit tests for detectPnpmMajorVersion, getPnpmVersionHelpersAsync, resolveDependencyKey (33 tests total, up from 7) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove detectPnpmMajorVersion; require pnpmVersion from caller The pnpm major version is always available from rush.json's packageManagerToolVersion, so there is no need to guess it from the lockfile format. Make pnpmVersion required in IComputeResolverCacheFromLockfileOptions and delete the detectPnpmMajorVersion helper and its tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address code review feedback - afterInstallAsync: switch/case with throw on unsupported pnpm versions - computeResolverCacheFromLockfileAsync: hoist backslash regex, use name ||= parsed.name, destructure lockfile.packages - helpers: flatten resolveDependencyKey link: branches into ternary, simplify package key resolution to single getDescriptionFileRootFromKey call - pnpm/index.ts: move types and factory to pnpmVersionHelpers.ts, index.ts now only re-exports - pnpm/v8,v9,v10: import from ./pnpmVersionHelpers instead of . - pnpm/store/v10: add Dirent to import, hoist scope separator regex Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1815739 commit 4c8fd4d

23 files changed

+1227
-121
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "rush-resolver-cache-plugin: add pnpm 10 / lockfile v9 compatibility",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
computeResolverCacheFromLockfileAsync,
1717
type IPlatformInfo
1818
} from './computeResolverCacheFromLockfileAsync';
19+
import {
20+
type PnpmMajorVersion,
21+
type IPnpmVersionHelpers,
22+
getPnpmVersionHelpersAsync
23+
} from './pnpm/pnpmVersionHelpers';
1924
import type { IResolverContext } from './types';
2025

2126
/**
@@ -79,10 +84,26 @@ export async function afterInstallAsync(
7984

8085
const lockFilePath: string = subspace.getCommittedShrinkwrapFilePath(variant);
8186

82-
const pnpmStoreDir: string = `${rushConfiguration.pnpmOptions.pnpmStorePath}/v3/files/`;
87+
const pnpmStorePath: string = rushConfiguration.pnpmOptions.pnpmStorePath;
88+
89+
const pnpmMajorVersion: PnpmMajorVersion = (() => {
90+
const major: number = parseInt(rushConfiguration.packageManagerToolVersion, 10);
91+
switch (major) {
92+
case 10:
93+
return 10;
94+
case 9:
95+
return 9;
96+
case 8:
97+
return 8;
98+
default:
99+
throw new Error(`Unsupported pnpm major version: ${major}`);
100+
}
101+
})();
102+
103+
const pnpmHelpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmMajorVersion);
83104

84105
terminal.writeLine(`Using pnpm-lock from: ${lockFilePath}`);
85-
terminal.writeLine(`Using pnpm store folder: ${pnpmStoreDir}`);
106+
terminal.writeLine(`Using pnpm ${pnpmMajorVersion} store at: ${pnpmStorePath}`);
86107

87108
const workspaceRoot: string = subspace.getSubspaceTempFolderPath();
88109
const cacheFilePath: string = `${workspaceRoot}/resolver-cache.json`;
@@ -166,10 +187,7 @@ export async function afterInstallAsync(
166187
const prefixIndex: number = descriptionFileHash.indexOf('-');
167188
const hash: string = Buffer.from(descriptionFileHash.slice(prefixIndex + 1), 'base64').toString('hex');
168189

169-
// The pnpm store directory has index files of package contents at paths:
170-
// <store>/v3/files/<hash (0-2)>/<hash (2-)>-index.json
171-
// See https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/store/cafs/src/getFilePathInCafs.ts#L33
172-
const indexPath: string = `${pnpmStoreDir}${hash.slice(0, 2)}/${hash.slice(2)}-index.json`;
190+
const indexPath: string = pnpmHelpers.getStoreIndexPath(pnpmStorePath, context, hash);
173191

174192
try {
175193
const indexContent: string = await FileSystem.readFileAsync(indexPath);
@@ -254,6 +272,7 @@ export async function afterInstallAsync(
254272
platformInfo: getPlatformInfo(),
255273
projectByImporterPath,
256274
lockfile: lockFile,
275+
pnpmVersion: pnpmMajorVersion,
257276
afterExternalPackagesAsync
258277
});
259278

rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ import type {
99
} from '@rushstack/webpack-workspace-resolve-plugin';
1010

1111
import type { PnpmShrinkwrapFile } from './externals';
12-
import { getDescriptionFileRootFromKey, resolveDependencies, createContextSerializer } from './helpers';
12+
import {
13+
getDescriptionFileRootFromKey,
14+
resolveDependencies,
15+
createContextSerializer,
16+
extractNameAndVersionFromKey
17+
} from './helpers';
18+
import {
19+
type PnpmMajorVersion,
20+
type IPnpmVersionHelpers,
21+
getPnpmVersionHelpersAsync
22+
} from './pnpm/pnpmVersionHelpers';
1323
import type { IResolverContext } from './types';
1424

1525
/**
@@ -105,6 +115,9 @@ function extractBundledDependencies(
105115
}
106116
}
107117

118+
// Re-export for downstream consumers
119+
export type { PnpmMajorVersion, IPnpmVersionHelpers } from './pnpm/pnpmVersionHelpers';
120+
108121
/**
109122
* Options for computing the resolver cache from a lockfile.
110123
*/
@@ -129,6 +142,11 @@ export interface IComputeResolverCacheFromLockfileOptions {
129142
* The lockfile to compute the cache from
130143
*/
131144
lockfile: PnpmShrinkwrapFile;
145+
/**
146+
* The major version of pnpm configured in rush.json (e.g. `"10.27.0"` → 10).
147+
* Used to select the correct dep-path hashing algorithm and store layout.
148+
*/
149+
pnpmVersion: PnpmMajorVersion;
132150
/**
133151
* A callback to process external packages after they have been enumerated.
134152
* Broken out as a separate function to facilitate testing without hitting the disk.
@@ -143,13 +161,15 @@ export interface IComputeResolverCacheFromLockfileOptions {
143161
) => Promise<void>;
144162
}
145163

164+
const BACKSLASH_REGEX: RegExp = /\\/g;
165+
146166
/**
147167
* Copied from `@rushstack/node-core-library/src/Path.ts` to avoid expensive dependency
148168
* @param path - Path using backslashes as path separators
149169
* @returns The same string using forward slashes as path separators
150170
*/
151171
function convertToSlashes(path: string): string {
152-
return path.replace(/\\/g, '/');
172+
return path.replace(BACKSLASH_REGEX, '/');
153173
}
154174

155175
/**
@@ -169,10 +189,19 @@ export async function computeResolverCacheFromLockfileAsync(
169189
const contexts: Map<string, IResolverContext> = new Map();
170190
const missingOptionalDependencies: Set<string> = new Set();
171191

192+
const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(params.pnpmVersion);
193+
194+
const { packages } = lockfile;
195+
172196
// Enumerate external dependencies first, to simplify looping over them for store data
173-
for (const [key, pack] of lockfile.packages) {
197+
for (const [key, pack] of packages) {
174198
let name: string | undefined = pack.name;
175-
const descriptionFileRoot: string = getDescriptionFileRootFromKey(workspaceRoot, key, name);
199+
const descriptionFileRoot: string = getDescriptionFileRootFromKey(
200+
workspaceRoot,
201+
key,
202+
helpers.depPathToFilename,
203+
name
204+
);
176205

177206
// Skip optional dependencies that are incompatible with the current environment
178207
if (pack.optional && !isPackageCompatible(pack, platformInfo)) {
@@ -182,9 +211,10 @@ export async function computeResolverCacheFromLockfileAsync(
182211

183212
const integrity: string | undefined = pack.resolution?.integrity;
184213

185-
if (!name && key.startsWith('/')) {
186-
const versionIndex: number = key.indexOf('@', 2);
187-
name = key.slice(1, versionIndex);
214+
// Extract name and version from the key if not already provided
215+
const parsed: { name: string; version: string } | undefined = extractNameAndVersionFromKey(key);
216+
if (parsed) {
217+
name ||= parsed.name;
188218
}
189219

190220
if (!name) {
@@ -196,6 +226,7 @@ export async function computeResolverCacheFromLockfileAsync(
196226
descriptionFileHash: integrity,
197227
isProject: false,
198228
name,
229+
version: parsed?.version,
199230
deps: new Map(),
200231
ordinal: -1,
201232
optional: pack.optional
@@ -204,10 +235,10 @@ export async function computeResolverCacheFromLockfileAsync(
204235
contexts.set(descriptionFileRoot, context);
205236

206237
if (pack.dependencies) {
207-
resolveDependencies(workspaceRoot, pack.dependencies, context);
238+
resolveDependencies(workspaceRoot, pack.dependencies, context, helpers, packages);
208239
}
209240
if (pack.optionalDependencies) {
210-
resolveDependencies(workspaceRoot, pack.optionalDependencies, context);
241+
resolveDependencies(workspaceRoot, pack.optionalDependencies, context, helpers, packages);
211242
}
212243
}
213244

@@ -248,13 +279,13 @@ export async function computeResolverCacheFromLockfileAsync(
248279
contexts.set(descriptionFileRoot, context);
249280

250281
if (importer.dependencies) {
251-
resolveDependencies(workspaceRoot, importer.dependencies, context);
282+
resolveDependencies(workspaceRoot, importer.dependencies, context, helpers, packages);
252283
}
253284
if (importer.devDependencies) {
254-
resolveDependencies(workspaceRoot, importer.devDependencies, context);
285+
resolveDependencies(workspaceRoot, importer.devDependencies, context, helpers, packages);
255286
}
256287
if (importer.optionalDependencies) {
257-
resolveDependencies(workspaceRoot, importer.optionalDependencies, context);
288+
resolveDependencies(workspaceRoot, importer.optionalDependencies, context, helpers, packages);
258289
}
259290
}
260291

rush-plugins/rush-resolver-cache-plugin/src/helpers.ts

Lines changed: 52 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,62 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
import { createHash } from 'node:crypto';
54
import * as path from 'node:path';
65

76
import type { ISerializedResolveContext } from '@rushstack/webpack-workspace-resolve-plugin';
87

98
import type { IDependencyEntry, IResolverContext } from './types';
10-
11-
const MAX_LENGTH_WITHOUT_HASH: number = 120 - 26 - 1;
12-
const BASE32: string[] = 'abcdefghijklmnopqrstuvwxyz234567'.split('');
13-
14-
// https://github.com/swansontec/rfc4648.js/blob/ead9c9b4b68e5d4a529f32925da02c02984e772c/src/codec.ts#L82-L118
15-
export function createBase32Hash(input: string): string {
16-
const data: Buffer = createHash('md5').update(input).digest();
17-
18-
const mask: 0x1f = 0x1f;
19-
let out: string = '';
20-
21-
let bits: number = 0; // Number of bits currently in the buffer
22-
let buffer: number = 0; // Bits waiting to be written out, MSB first
23-
for (let i: number = 0; i < data.length; ++i) {
24-
// eslint-disable-next-line no-bitwise
25-
buffer = (buffer << 8) | (0xff & data[i]);
26-
bits += 8;
27-
28-
// Write out as much as we can:
29-
while (bits > 5) {
30-
bits -= 5;
31-
// eslint-disable-next-line no-bitwise
32-
out += BASE32[mask & (buffer >> bits)];
33-
}
34-
}
35-
36-
// Partial character:
37-
if (bits) {
38-
// eslint-disable-next-line no-bitwise
39-
out += BASE32[mask & (buffer << (5 - bits))];
40-
}
41-
42-
return out;
43-
}
44-
45-
// https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/packages/dependency-path/src/index.ts#L167-L189
46-
export function depPathToFilename(depPath: string): string {
47-
let filename: string = depPathToFilenameUnescaped(depPath).replace(/[\\/:*?"<>|]/g, '+');
48-
if (filename.includes('(')) {
49-
filename = filename.replace(/(\)\()|\(/g, '_').replace(/\)$/, '');
50-
}
51-
if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
52-
return `${filename.substring(0, MAX_LENGTH_WITHOUT_HASH)}_${createBase32Hash(filename)}`;
53-
}
54-
return filename;
55-
}
9+
import type { IPnpmVersionHelpers } from './pnpm/pnpmVersionHelpers';
5610

5711
/**
5812
* Computes the root folder for a dependency from a reference to it in another package
5913
* @param lockfileFolder - The folder that contains the lockfile
6014
* @param key - The key of the dependency
6115
* @param specifier - The specifier in the lockfile for the dependency
6216
* @param context - The owning package
17+
* @param helpers - Version-specific pnpm helpers
6318
* @returns The identifier for the dependency
6419
*/
6520
export function resolveDependencyKey(
6621
lockfileFolder: string,
6722
key: string,
6823
specifier: string,
69-
context: IResolverContext
24+
context: IResolverContext,
25+
helpers: IPnpmVersionHelpers,
26+
packageKeys?: { has(key: string): boolean }
7027
): string {
71-
if (specifier.startsWith('/')) {
72-
return getDescriptionFileRootFromKey(lockfileFolder, specifier);
73-
} else if (specifier.startsWith('link:')) {
74-
if (context.isProject) {
75-
return path.posix.join(context.descriptionFileRoot, specifier.slice(5));
76-
} else {
77-
return path.posix.join(lockfileFolder, specifier.slice(5));
78-
}
28+
if (specifier.startsWith('link:')) {
29+
return path.posix.join(
30+
context.isProject ? context.descriptionFileRoot : lockfileFolder,
31+
specifier.slice(5)
32+
);
7933
} else if (specifier.startsWith('file:')) {
80-
return getDescriptionFileRootFromKey(lockfileFolder, specifier, key);
34+
return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename, key);
8135
} else {
82-
return getDescriptionFileRootFromKey(lockfileFolder, `/${key}@${specifier}`);
36+
const resolvedKey: string = packageKeys?.has(specifier)
37+
? specifier
38+
: helpers.buildDependencyKey(key, specifier);
39+
return getDescriptionFileRootFromKey(lockfileFolder, resolvedKey, helpers.depPathToFilename);
8340
}
8441
}
8542

8643
/**
8744
* Computes the physical path to a dependency based on its entry
8845
* @param lockfileFolder - The folder that contains the lockfile during installation
8946
* @param key - The key of the dependency
47+
* @param depPathToFilename - Version-specific function to convert dep paths to filenames
9048
* @param name - The name of the dependency, if provided
9149
* @returns The physical path to the dependency
9250
*/
93-
export function getDescriptionFileRootFromKey(lockfileFolder: string, key: string, name?: string): string {
94-
if (!key.startsWith('file:')) {
95-
name = key.slice(1, key.indexOf('@', 2));
51+
export function getDescriptionFileRootFromKey(
52+
lockfileFolder: string,
53+
key: string,
54+
depPathToFilename: (depPath: string) => string,
55+
name?: string
56+
): string {
57+
if (!key.startsWith('file:') && !name) {
58+
const offset: number = key.startsWith('/') ? 1 : 0;
59+
name = key.slice(offset, key.indexOf('@', offset + 1));
9660
}
9761
if (!name) {
9862
throw new Error(`Missing package name for ${key}`);
@@ -106,29 +70,44 @@ export function getDescriptionFileRootFromKey(lockfileFolder: string, key: strin
10670
export function resolveDependencies(
10771
lockfileFolder: string,
10872
collection: Record<string, IDependencyEntry>,
109-
context: IResolverContext
73+
context: IResolverContext,
74+
helpers: IPnpmVersionHelpers,
75+
packageKeys?: { has(key: string): boolean }
11076
): void {
11177
for (const [key, value] of Object.entries(collection)) {
11278
const version: string = typeof value === 'string' ? value : value.version;
113-
const resolved: string = resolveDependencyKey(lockfileFolder, key, version, context);
79+
const resolved: string = resolveDependencyKey(
80+
lockfileFolder,
81+
key,
82+
version,
83+
context,
84+
helpers,
85+
packageKeys
86+
);
11487

11588
context.deps.set(key, resolved);
11689
}
11790
}
11891

11992
/**
120-
*
121-
* @param depPath - The path to the dependency
122-
* @returns The folder name for the dependency
93+
* Extracts the package name and version from a lockfile package key.
94+
* @param key - The lockfile package key (e.g. '/autoprefixer\@9.8.8', '\@scope/name\@1.0.0(peer\@2.0.0)')
95+
* @returns The extracted name and version, or undefined for file: keys
12396
*/
124-
export function depPathToFilenameUnescaped(depPath: string): string {
125-
if (depPath.indexOf('file:') !== 0) {
126-
if (depPath.startsWith('/')) {
127-
depPath = depPath.slice(1);
128-
}
129-
return depPath;
97+
export function extractNameAndVersionFromKey(key: string): { name: string; version: string } | undefined {
98+
if (key.startsWith('file:')) {
99+
return undefined;
100+
}
101+
const offset: number = key.startsWith('/') ? 1 : 0;
102+
const versionAtIndex: number = key.indexOf('@', offset + 1);
103+
if (versionAtIndex === -1) {
104+
return undefined;
130105
}
131-
return depPath.replace(':', '+');
106+
const name: string = key.slice(offset, versionAtIndex);
107+
const parenIndex: number = key.indexOf('(', versionAtIndex);
108+
const version: string =
109+
parenIndex !== -1 ? key.slice(versionAtIndex + 1, parenIndex) : key.slice(versionAtIndex + 1);
110+
return { name, version };
132111
}
133112

134113
/**

0 commit comments

Comments
 (0)