Skip to content

Commit e9b5c80

Browse files
bmiddhaCopilot
andauthored
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>
1 parent f7eebd6 commit e9b5c80

23 files changed

Lines changed: 1279 additions & 109 deletions
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: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
computeResolverCacheFromLockfileAsync,
1717
type IPlatformInfo
1818
} from './computeResolverCacheFromLockfileAsync';
19+
import { type PnpmMajorVersion, type IPnpmVersionHelpers, getPnpmVersionHelpersAsync } from './pnpm';
1920
import type { IResolverContext } from './types';
2021

2122
/**
@@ -79,10 +80,19 @@ export async function afterInstallAsync(
7980

8081
const lockFilePath: string = subspace.getCommittedShrinkwrapFilePath(variant);
8182

82-
const pnpmStoreDir: string = `${rushConfiguration.pnpmOptions.pnpmStorePath}/v3/files/`;
83+
const pnpmStorePath: string = rushConfiguration.pnpmOptions.pnpmStorePath;
84+
85+
const pnpmMajorVersion: PnpmMajorVersion = (() => {
86+
const major: number = parseInt(rushConfiguration.packageManagerToolVersion, 10);
87+
if (major >= 10) return 10;
88+
if (major >= 9) return 9;
89+
return 8;
90+
})() as PnpmMajorVersion;
91+
92+
const pnpmHelpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmMajorVersion);
8393

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

8797
const workspaceRoot: string = subspace.getSubspaceTempFolderPath();
8898
const cacheFilePath: string = `${workspaceRoot}/resolver-cache.json`;
@@ -166,10 +176,7 @@ export async function afterInstallAsync(
166176
const prefixIndex: number = descriptionFileHash.indexOf('-');
167177
const hash: string = Buffer.from(descriptionFileHash.slice(prefixIndex + 1), 'base64').toString('hex');
168178

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`;
179+
const indexPath: string = pnpmHelpers.getStoreIndexPath(pnpmStorePath, context, hash);
173180

174181
try {
175182
const indexContent: string = await FileSystem.readFileAsync(indexPath);
@@ -254,6 +261,7 @@ export async function afterInstallAsync(
254261
platformInfo: getPlatformInfo(),
255262
projectByImporterPath,
256263
lockfile: lockFile,
264+
pnpmVersion: pnpmMajorVersion,
257265
afterExternalPackagesAsync
258266
});
259267

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

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ 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 { type PnpmMajorVersion, type IPnpmVersionHelpers, getPnpmVersionHelpersAsync } from './pnpm';
1319
import type { IResolverContext } from './types';
1420

1521
/**
@@ -105,6 +111,9 @@ function extractBundledDependencies(
105111
}
106112
}
107113

114+
// Re-export for downstream consumers
115+
export type { PnpmMajorVersion, IPnpmVersionHelpers } from './pnpm';
116+
108117
/**
109118
* Options for computing the resolver cache from a lockfile.
110119
*/
@@ -129,6 +138,13 @@ export interface IComputeResolverCacheFromLockfileOptions {
129138
* The lockfile to compute the cache from
130139
*/
131140
lockfile: PnpmShrinkwrapFile;
141+
/**
142+
* The major version of pnpm configured in rush.json (e.g. `"10.27.0"` → 10).
143+
* Used to select the correct dep-path hashing algorithm and store layout.
144+
* When omitted, the version is inferred from the lockfile format (v6 → pnpm 8,
145+
* v9 → pnpm 9).
146+
*/
147+
pnpmVersion?: PnpmMajorVersion;
132148
/**
133149
* A callback to process external packages after they have been enumerated.
134150
* Broken out as a separate function to facilitate testing without hitting the disk.
@@ -152,6 +168,44 @@ function convertToSlashes(path: string): string {
152168
return path.replace(/\\/g, '/');
153169
}
154170

171+
/**
172+
* Detects the pnpm major version from the lockfile format and an optional
173+
* caller-supplied version (derived from rush.json `pnpmVersion`).
174+
*
175+
* @param lockfile - The parsed shrinkwrap / lockfile
176+
* @param configuredPnpmVersion - The pnpm major version from rush.json, if available.
177+
* When provided this takes precedence, because the lockfile alone cannot distinguish
178+
* pnpm 9 from pnpm 10 (both use lockfile v9).
179+
*/
180+
export function detectPnpmMajorVersion(
181+
lockfile: PnpmShrinkwrapFile,
182+
configuredPnpmVersion?: PnpmMajorVersion
183+
): PnpmMajorVersion {
184+
if (configuredPnpmVersion !== undefined) {
185+
return configuredPnpmVersion;
186+
}
187+
188+
// Detect from lockfile version
189+
if (lockfile.shrinkwrapFileMajorVersion >= 9) {
190+
// Lockfile v9 is shared by pnpm 9 and pnpm 10.
191+
// Without the configured version we cannot tell them apart; default to 9
192+
// (v8 dep-path algorithm, v3 store, v9 key format).
193+
return 9;
194+
}
195+
196+
if (lockfile.shrinkwrapFileMajorVersion > 0) {
197+
return 8;
198+
}
199+
200+
// Fallback for lockfiles where version parsing failed: inspect the first non-file package key.
201+
for (const key of lockfile.packages.keys()) {
202+
if (!key.startsWith('file:')) {
203+
return key.startsWith('/') ? 8 : 9;
204+
}
205+
}
206+
return 8;
207+
}
208+
155209
/**
156210
* Given a lockfile and information about the workspace and platform, computes the resolver cache file.
157211
* @param params - The options for computing the resolver cache
@@ -169,10 +223,19 @@ export async function computeResolverCacheFromLockfileAsync(
169223
const contexts: Map<string, IResolverContext> = new Map();
170224
const missingOptionalDependencies: Set<string> = new Set();
171225

226+
const pnpmVersion: PnpmMajorVersion = detectPnpmMajorVersion(lockfile, params.pnpmVersion);
227+
228+
const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmVersion);
229+
172230
// Enumerate external dependencies first, to simplify looping over them for store data
173231
for (const [key, pack] of lockfile.packages) {
174232
let name: string | undefined = pack.name;
175-
const descriptionFileRoot: string = getDescriptionFileRootFromKey(workspaceRoot, key, name);
233+
const descriptionFileRoot: string = getDescriptionFileRootFromKey(
234+
workspaceRoot,
235+
key,
236+
helpers.depPathToFilename,
237+
name
238+
);
176239

177240
// Skip optional dependencies that are incompatible with the current environment
178241
if (pack.optional && !isPackageCompatible(pack, platformInfo)) {
@@ -182,9 +245,12 @@ export async function computeResolverCacheFromLockfileAsync(
182245

183246
const integrity: string | undefined = pack.resolution?.integrity;
184247

185-
if (!name && key.startsWith('/')) {
186-
const versionIndex: number = key.indexOf('@', 2);
187-
name = key.slice(1, versionIndex);
248+
// Extract name and version from the key if not already provided
249+
const parsed: { name: string; version: string } | undefined = extractNameAndVersionFromKey(key);
250+
if (parsed) {
251+
if (!name) {
252+
name = parsed.name;
253+
}
188254
}
189255

190256
if (!name) {
@@ -196,6 +262,7 @@ export async function computeResolverCacheFromLockfileAsync(
196262
descriptionFileHash: integrity,
197263
isProject: false,
198264
name,
265+
version: parsed?.version,
199266
deps: new Map(),
200267
ordinal: -1,
201268
optional: pack.optional
@@ -204,10 +271,10 @@ export async function computeResolverCacheFromLockfileAsync(
204271
contexts.set(descriptionFileRoot, context);
205272

206273
if (pack.dependencies) {
207-
resolveDependencies(workspaceRoot, pack.dependencies, context);
274+
resolveDependencies(workspaceRoot, pack.dependencies, context, helpers, lockfile.packages);
208275
}
209276
if (pack.optionalDependencies) {
210-
resolveDependencies(workspaceRoot, pack.optionalDependencies, context);
277+
resolveDependencies(workspaceRoot, pack.optionalDependencies, context, helpers, lockfile.packages);
211278
}
212279
}
213280

@@ -248,13 +315,13 @@ export async function computeResolverCacheFromLockfileAsync(
248315
contexts.set(descriptionFileRoot, context);
249316

250317
if (importer.dependencies) {
251-
resolveDependencies(workspaceRoot, importer.dependencies, context);
318+
resolveDependencies(workspaceRoot, importer.dependencies, context, helpers, lockfile.packages);
252319
}
253320
if (importer.devDependencies) {
254-
resolveDependencies(workspaceRoot, importer.devDependencies, context);
321+
resolveDependencies(workspaceRoot, importer.devDependencies, context, helpers, lockfile.packages);
255322
}
256323
if (importer.optionalDependencies) {
257-
resolveDependencies(workspaceRoot, importer.optionalDependencies, context);
324+
resolveDependencies(workspaceRoot, importer.optionalDependencies, context, helpers, lockfile.packages);
258325
}
259326
}
260327

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

Lines changed: 49 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,64 @@
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';
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:')) {
28+
if (specifier.startsWith('link:')) {
7429
if (context.isProject) {
7530
return path.posix.join(context.descriptionFileRoot, specifier.slice(5));
7631
} else {
7732
return path.posix.join(lockfileFolder, specifier.slice(5));
7833
}
7934
} else if (specifier.startsWith('file:')) {
80-
return getDescriptionFileRootFromKey(lockfileFolder, specifier, key);
35+
return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename, key);
36+
} else if (packageKeys?.has(specifier)) {
37+
// The specifier is a full package key (v6: '/pkg@ver', v9: 'pkg@ver')
38+
return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename);
8139
} else {
82-
return getDescriptionFileRootFromKey(lockfileFolder, `/${key}@${specifier}`);
40+
const fullKey: string = helpers.buildDependencyKey(key, specifier);
41+
return getDescriptionFileRootFromKey(lockfileFolder, fullKey, helpers.depPathToFilename);
8342
}
8443
}
8544

8645
/**
8746
* Computes the physical path to a dependency based on its entry
8847
* @param lockfileFolder - The folder that contains the lockfile during installation
8948
* @param key - The key of the dependency
49+
* @param depPathToFilename - Version-specific function to convert dep paths to filenames
9050
* @param name - The name of the dependency, if provided
9151
* @returns The physical path to the dependency
9252
*/
93-
export function getDescriptionFileRootFromKey(lockfileFolder: string, key: string, name?: string): string {
94-
if (!key.startsWith('file:')) {
95-
name = key.slice(1, key.indexOf('@', 2));
53+
export function getDescriptionFileRootFromKey(
54+
lockfileFolder: string,
55+
key: string,
56+
depPathToFilename: (depPath: string) => string,
57+
name?: string
58+
): string {
59+
if (!key.startsWith('file:') && !name) {
60+
const offset: number = key.startsWith('/') ? 1 : 0;
61+
name = key.slice(offset, key.indexOf('@', offset + 1));
9662
}
9763
if (!name) {
9864
throw new Error(`Missing package name for ${key}`);
@@ -106,29 +72,44 @@ export function getDescriptionFileRootFromKey(lockfileFolder: string, key: strin
10672
export function resolveDependencies(
10773
lockfileFolder: string,
10874
collection: Record<string, IDependencyEntry>,
109-
context: IResolverContext
75+
context: IResolverContext,
76+
helpers: IPnpmVersionHelpers,
77+
packageKeys?: { has(key: string): boolean }
11078
): void {
11179
for (const [key, value] of Object.entries(collection)) {
11280
const version: string = typeof value === 'string' ? value : value.version;
113-
const resolved: string = resolveDependencyKey(lockfileFolder, key, version, context);
81+
const resolved: string = resolveDependencyKey(
82+
lockfileFolder,
83+
key,
84+
version,
85+
context,
86+
helpers,
87+
packageKeys
88+
);
11489

11590
context.deps.set(key, resolved);
11691
}
11792
}
11893

11994
/**
120-
*
121-
* @param depPath - The path to the dependency
122-
* @returns The folder name for the dependency
95+
* Extracts the package name and version from a lockfile package key.
96+
* @param key - The lockfile package key (e.g. '/autoprefixer\@9.8.8', '\@scope/name\@1.0.0(peer\@2.0.0)')
97+
* @returns The extracted name and version, or undefined for file: keys
12398
*/
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;
99+
export function extractNameAndVersionFromKey(key: string): { name: string; version: string } | undefined {
100+
if (key.startsWith('file:')) {
101+
return undefined;
102+
}
103+
const offset: number = key.startsWith('/') ? 1 : 0;
104+
const versionAtIndex: number = key.indexOf('@', offset + 1);
105+
if (versionAtIndex === -1) {
106+
return undefined;
130107
}
131-
return depPath.replace(':', '+');
108+
const name: string = key.slice(offset, versionAtIndex);
109+
const parenIndex: number = key.indexOf('(', versionAtIndex);
110+
const version: string =
111+
parenIndex !== -1 ? key.slice(versionAtIndex + 1, parenIndex) : key.slice(versionAtIndex + 1);
112+
return { name, version };
132113
}
133114

134115
/**

0 commit comments

Comments
 (0)