Skip to content

Commit 3ff472b

Browse files
authored
rush-resolver-cache-plugin: add pnpm 10 / lockfile v9 compatibility
1 parent f7eebd6 commit 3ff472b

6 files changed

Lines changed: 234 additions & 40 deletions

File tree

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: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
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 { existsSync, readdirSync } from 'node:fs';
5+
46
import type {
57
RushSession,
68
RushConfiguration,
@@ -79,7 +81,12 @@ export async function afterInstallAsync(
7981

8082
const lockFilePath: string = subspace.getCommittedShrinkwrapFilePath(variant);
8183

82-
const pnpmStoreDir: string = `${rushConfiguration.pnpmOptions.pnpmStorePath}/v3/files/`;
84+
const pnpmStorePath: string = rushConfiguration.pnpmOptions.pnpmStorePath;
85+
// pnpm 10 uses v10/index/ for index files; pnpm 8 uses v3/files/
86+
const pnpmStoreV10IndexDir: string = `${pnpmStorePath}/v10/index/`;
87+
const pnpmStoreV3FilesDir: string = `${pnpmStorePath}/v3/files/`;
88+
const useV10Store: boolean = existsSync(pnpmStoreV10IndexDir);
89+
const pnpmStoreDir: string = useV10Store ? pnpmStoreV10IndexDir : pnpmStoreV3FilesDir;
8390

8491
terminal.writeLine(`Using pnpm-lock from: ${lockFilePath}`);
8592
terminal.writeLine(`Using pnpm store folder: ${pnpmStoreDir}`);
@@ -167,9 +174,49 @@ export async function afterInstallAsync(
167174
const hash: string = Buffer.from(descriptionFileHash.slice(prefixIndex + 1), 'base64').toString('hex');
168175

169176
// The pnpm store directory has index files of package contents at paths:
170-
// <store>/v3/files/<hash (0-2)>/<hash (2-)>-index.json
177+
// pnpm 8: <store>/v3/files/<hash (0-2)>/<hash (2-)>-index.json
178+
// pnpm 10: <store>/v10/index/<hash (0-2)>/<hash (2-64)>-<name>@<version>.json
171179
// 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`;
180+
let indexPath: string;
181+
if (useV10Store) {
182+
// pnpm 10 truncates integrity hashes to 32 bytes (64 hex chars) for index paths.
183+
const truncHash: string = hash.length > 64 ? hash.slice(0, 64) : hash;
184+
const hashDir: string = truncHash.slice(0, 2);
185+
const hashRest: string = truncHash.slice(2);
186+
// Build the bare name@version using context.name and version from the .pnpm folder path.
187+
// The .pnpm folder name format is: <name+ver>_<peer1>_<peer2>/node_modules/<name>
188+
const pkgName: string = (context.name || '').replace(/\//g, '+');
189+
const pnpmSegment: string | undefined = context.descriptionFileRoot.split('/node_modules/.pnpm/')[1];
190+
const folderName: string = pnpmSegment ? pnpmSegment.split('/node_modules/')[0] : '';
191+
const namePrefix: string = `${pkgName}@`;
192+
const nameStart: number = folderName.indexOf(namePrefix);
193+
let nameVer: string = folderName;
194+
if (nameStart !== -1) {
195+
const afterName: string = folderName.slice(nameStart + namePrefix.length);
196+
// Version ends at first _ followed by a letter/@ (peer dep separator)
197+
const peerSep: number = afterName.search(/_[a-zA-Z@]/);
198+
const version: string = peerSep !== -1 ? afterName.slice(0, peerSep) : afterName;
199+
nameVer = `${pkgName}@${version}`;
200+
}
201+
indexPath = `${pnpmStoreDir}${hashDir}/${hashRest}-${nameVer}.json`;
202+
// For truncated/hashed folder names, nameVer from the folder path may be wrong.
203+
// Fallback: scan the directory for a file matching the hash prefix.
204+
if (!existsSync(indexPath)) {
205+
const dir: string = `${pnpmStoreDir}${hashDir}/`;
206+
const filePrefix: string = `${hashRest}-`;
207+
try {
208+
const files: string[] = readdirSync(dir);
209+
const match: string | undefined = files.find((f) => f.startsWith(filePrefix));
210+
if (match) {
211+
indexPath = dir + match;
212+
}
213+
} catch {
214+
// ignore
215+
}
216+
}
217+
} else {
218+
indexPath = `${pnpmStoreDir}${hash.slice(0, 2)}/${hash.slice(2)}-index.json`;
219+
}
173220

174221
try {
175222
const indexContent: string = await FileSystem.readFileAsync(indexPath);

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ export async function computeResolverCacheFromLockfileAsync(
169169
const contexts: Map<string, IResolverContext> = new Map();
170170
const missingOptionalDependencies: Set<string> = new Set();
171171

172+
// Detect v9+ lockfile format by checking if the lockfile has shrinkwrapFileMajorVersion >= 9,
173+
// or by checking if the first package key lacks a leading '/' (v6 keys always start with '/').
174+
const isV9Lockfile: boolean =
175+
(lockfile as { shrinkwrapFileMajorVersion?: number }).shrinkwrapFileMajorVersion !== undefined
176+
? (lockfile as { shrinkwrapFileMajorVersion?: number }).shrinkwrapFileMajorVersion! >= 9
177+
: !Array.from(lockfile.packages.keys())
178+
.find((k) => !k.startsWith('file:'))
179+
?.startsWith('/');
180+
172181
// Enumerate external dependencies first, to simplify looping over them for store data
173182
for (const [key, pack] of lockfile.packages) {
174183
let name: string | undefined = pack.name;
@@ -187,6 +196,15 @@ export async function computeResolverCacheFromLockfileAsync(
187196
name = key.slice(1, versionIndex);
188197
}
189198

199+
if (!name) {
200+
// Handle v9 lockfile keys: @scope/name@version or name@version
201+
const searchStart: number = key.startsWith('@') ? key.indexOf('/') + 1 : 0;
202+
const versionIndex: number = key.indexOf('@', searchStart);
203+
if (versionIndex !== -1) {
204+
name = key.slice(0, versionIndex);
205+
}
206+
}
207+
190208
if (!name) {
191209
throw new Error(`Missing name for ${key}`);
192210
}
@@ -204,10 +222,10 @@ export async function computeResolverCacheFromLockfileAsync(
204222
contexts.set(descriptionFileRoot, context);
205223

206224
if (pack.dependencies) {
207-
resolveDependencies(workspaceRoot, pack.dependencies, context);
225+
resolveDependencies(workspaceRoot, pack.dependencies, context, isV9Lockfile);
208226
}
209227
if (pack.optionalDependencies) {
210-
resolveDependencies(workspaceRoot, pack.optionalDependencies, context);
228+
resolveDependencies(workspaceRoot, pack.optionalDependencies, context, isV9Lockfile);
211229
}
212230
}
213231

@@ -248,13 +266,13 @@ export async function computeResolverCacheFromLockfileAsync(
248266
contexts.set(descriptionFileRoot, context);
249267

250268
if (importer.dependencies) {
251-
resolveDependencies(workspaceRoot, importer.dependencies, context);
269+
resolveDependencies(workspaceRoot, importer.dependencies, context, isV9Lockfile);
252270
}
253271
if (importer.devDependencies) {
254-
resolveDependencies(workspaceRoot, importer.devDependencies, context);
272+
resolveDependencies(workspaceRoot, importer.devDependencies, context, isV9Lockfile);
255273
}
256274
if (importer.optionalDependencies) {
257-
resolveDependencies(workspaceRoot, importer.optionalDependencies, context);
275+
resolveDependencies(workspaceRoot, importer.optionalDependencies, context, isV9Lockfile);
258276
}
259277
}
260278

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

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import type { ISerializedResolveContext } from '@rushstack/webpack-workspace-res
88

99
import type { IDependencyEntry, IResolverContext } from './types';
1010

11-
const MAX_LENGTH_WITHOUT_HASH: number = 120 - 26 - 1;
11+
const PNPM8_MAX_LENGTH_WITHOUT_HASH: number = 120 - 26 - 1;
12+
// pnpm 10 uses SHA-256 hex (32 chars) + underscore separator
13+
const PNPM10_MAX_LENGTH_WITHOUT_HASH: number = 120 - 32 - 1;
1214
const BASE32: string[] = 'abcdefghijklmnopqrstuvwxyz234567'.split('');
1315

1416
// https://github.com/swansontec/rfc4648.js/blob/ead9c9b4b68e5d4a529f32925da02c02984e772c/src/codec.ts#L82-L118
@@ -42,14 +44,32 @@ export function createBase32Hash(input: string): string {
4244
return out;
4345
}
4446

47+
/**
48+
* Creates a short SHA-256 hex hash, matching pnpm 10's createShortHash.
49+
*/
50+
export function createShortSha256Hash(input: string): string {
51+
return createHash('sha256').update(input).digest('hex').substring(0, 32);
52+
}
53+
4554
// https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/packages/dependency-path/src/index.ts#L167-L189
46-
export function depPathToFilename(depPath: string): string {
55+
export function depPathToFilename(depPath: string, usePnpm10Hashing?: boolean): string {
4756
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)}`;
57+
if (usePnpm10Hashing) {
58+
// pnpm 10 also replaces `#` and handles parentheses differently
59+
filename = filename.replace(/#/g, '+');
60+
if (filename.includes('(')) {
61+
filename = filename.replace(/\)$/, '').replace(/\)\(|\(|\)/g, '_');
62+
}
63+
if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
64+
return `${filename.substring(0, PNPM10_MAX_LENGTH_WITHOUT_HASH)}_${createShortSha256Hash(filename)}`;
65+
}
66+
} else {
67+
if (filename.includes('(')) {
68+
filename = filename.replace(/(\)\()|\(/g, '_').replace(/\)$/, '');
69+
}
70+
if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
71+
return `${filename.substring(0, PNPM8_MAX_LENGTH_WITHOUT_HASH)}_${createBase32Hash(filename)}`;
72+
}
5373
}
5474
return filename;
5575
}
@@ -66,7 +86,8 @@ export function resolveDependencyKey(
6686
lockfileFolder: string,
6787
key: string,
6888
specifier: string,
69-
context: IResolverContext
89+
context: IResolverContext,
90+
isV9Lockfile?: boolean
7091
): string {
7192
if (specifier.startsWith('/')) {
7293
return getDescriptionFileRootFromKey(lockfileFolder, specifier);
@@ -79,7 +100,16 @@ export function resolveDependencyKey(
79100
} else if (specifier.startsWith('file:')) {
80101
return getDescriptionFileRootFromKey(lockfileFolder, specifier, key);
81102
} else {
82-
return getDescriptionFileRootFromKey(lockfileFolder, `/${key}@${specifier}`);
103+
// In v9 lockfiles, aliased dependency values use the full package key format
104+
// (e.g., 'string-width@4.2.3' or '@types/events@3.0.0') instead of bare versions.
105+
// A bare version starts with a digit; a full key starts with a letter or @.
106+
if (/^[a-zA-Z@]/.test(specifier)) {
107+
return getDescriptionFileRootFromKey(lockfileFolder, specifier);
108+
}
109+
// Construct the full dependency key from package name and version specifier.
110+
// v6 keys use '/' prefix; v9 keys don't.
111+
const fullKey: string = isV9Lockfile ? `${key}@${specifier}` : `/${key}@${specifier}`;
112+
return getDescriptionFileRootFromKey(lockfileFolder, fullKey);
83113
}
84114
}
85115

@@ -91,26 +121,40 @@ export function resolveDependencyKey(
91121
* @returns The physical path to the dependency
92122
*/
93123
export function getDescriptionFileRootFromKey(lockfileFolder: string, key: string, name?: string): string {
124+
// Detect lockfile version: v6 keys start with '/', v9 keys don't
125+
const isV9Key: boolean = !key.startsWith('/') && !key.startsWith('file:');
126+
94127
if (!key.startsWith('file:')) {
95-
name = key.slice(1, key.indexOf('@', 2));
128+
if (key.startsWith('/')) {
129+
// v6 format: /name@version or /@scope/name@version
130+
name = key.slice(1, key.indexOf('@', 2));
131+
} else if (!name) {
132+
// v9 format: name@version or @scope/name@version
133+
const searchStart: number = key.startsWith('@') ? key.indexOf('/') + 1 : 0;
134+
const versionIndex: number = key.indexOf('@', searchStart);
135+
if (versionIndex !== -1) {
136+
name = key.slice(0, versionIndex);
137+
}
138+
}
96139
}
97140
if (!name) {
98141
throw new Error(`Missing package name for ${key}`);
99142
}
100143

101-
const originFolder: string = `${lockfileFolder}/node_modules/.pnpm/${depPathToFilename(key)}/node_modules`;
144+
const originFolder: string = `${lockfileFolder}/node_modules/.pnpm/${depPathToFilename(key, isV9Key)}/node_modules`;
102145
const descriptionFileRoot: string = `${originFolder}/${name}`;
103146
return descriptionFileRoot;
104147
}
105148

106149
export function resolveDependencies(
107150
lockfileFolder: string,
108151
collection: Record<string, IDependencyEntry>,
109-
context: IResolverContext
152+
context: IResolverContext,
153+
isV9Lockfile?: boolean
110154
): void {
111155
for (const [key, value] of Object.entries(collection)) {
112156
const version: string = typeof value === 'string' ? value : value.version;
113-
const resolved: string = resolveDependencyKey(lockfileFolder, key, version, context);
157+
const resolved: string = resolveDependencyKey(lockfileFolder, key, version, context, isV9Lockfile);
114158

115159
context.deps.set(key, resolved);
116160
}

0 commit comments

Comments
 (0)