Skip to content

Commit 44fb50b

Browse files
bmiddhaCopilot
andauthored
Address PR #5749 review feedback
- Hoist regexes in depPathToFilename to module-level constants - Unify v6/v9 branches in getDescriptionFileRootFromKey using offset - Check specifier against packages list instead of regex heuristic - Extract detectV9Lockfile helper (iterates with early return, no clone) - Store version on IResolverContext when parsing lockfile keys - Extract getStoreIndexPath helper in afterInstallAsync - Use context.version instead of .split() on hot path - Fix undefined in snapshot test names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3ff472b commit 44fb50b

6 files changed

Lines changed: 125 additions & 109 deletions

File tree

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

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,40 @@ export async function afterInstallAsync(
143143
// Ignore
144144
}
145145

146+
/**
147+
* Computes the pnpm store index file path for a given package integrity hash.
148+
*/
149+
function getStoreIndexPath(context: IResolverContext, hash: string): string {
150+
if (!useV10Store) {
151+
return `${pnpmStoreDir}${hash.slice(0, 2)}/${hash.slice(2)}-index.json`;
152+
}
153+
154+
// pnpm 10 truncates integrity hashes to 32 bytes (64 hex chars) for index paths.
155+
const truncHash: string = hash.length > 64 ? hash.slice(0, 64) : hash;
156+
const hashDir: string = truncHash.slice(0, 2);
157+
const hashRest: string = truncHash.slice(2);
158+
// pnpm 10 index path format: <hash (0-2)>/<hash (2-64)>-<name>@<version>.json
159+
const pkgName: string = (context.name || '').replace(/\//g, '+');
160+
const nameVer: string = context.version ? `${pkgName}@${context.version}` : pkgName;
161+
let indexPath: string = `${pnpmStoreDir}${hashDir}/${hashRest}-${nameVer}.json`;
162+
// For truncated/hashed folder names, nameVer from the key may be wrong.
163+
// Fallback: scan the directory for a file matching the hash prefix.
164+
if (!existsSync(indexPath)) {
165+
const dir: string = `${pnpmStoreDir}${hashDir}/`;
166+
const filePrefix: string = `${hashRest}-`;
167+
try {
168+
const files: string[] = readdirSync(dir);
169+
const match: string | undefined = files.find((f) => f.startsWith(filePrefix));
170+
if (match) {
171+
indexPath = dir + match;
172+
}
173+
} catch {
174+
// ignore
175+
}
176+
}
177+
return indexPath;
178+
}
179+
146180
async function afterExternalPackagesAsync(
147181
contexts: Map<string, IResolverContext>,
148182
missingOptionalDependencies: Set<string>
@@ -173,50 +207,7 @@ export async function afterInstallAsync(
173207
const prefixIndex: number = descriptionFileHash.indexOf('-');
174208
const hash: string = Buffer.from(descriptionFileHash.slice(prefixIndex + 1), 'base64').toString('hex');
175209

176-
// The pnpm store directory has index files of package contents at paths:
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
179-
// See https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/store/cafs/src/getFilePathInCafs.ts#L33
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-
}
210+
const indexPath: string = getStoreIndexPath(context, hash);
220211

221212
try {
222213
const indexContent: string = await FileSystem.readFileAsync(indexPath);

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

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,24 @@ function convertToSlashes(path: string): string {
152152
return path.replace(/\\/g, '/');
153153
}
154154

155+
/**
156+
* Detects whether the lockfile uses v9+ format by checking the major version
157+
* or inspecting the first non-file package key.
158+
*/
159+
function detectV9Lockfile(lockfile: PnpmShrinkwrapFile): boolean {
160+
const majorVersion: number | undefined = (lockfile as { shrinkwrapFileMajorVersion?: number })
161+
.shrinkwrapFileMajorVersion;
162+
if (majorVersion !== undefined) {
163+
return majorVersion >= 9;
164+
}
165+
for (const key of lockfile.packages.keys()) {
166+
if (!key.startsWith('file:')) {
167+
return !key.startsWith('/');
168+
}
169+
}
170+
return false;
171+
}
172+
155173
/**
156174
* Given a lockfile and information about the workspace and platform, computes the resolver cache file.
157175
* @param params - The options for computing the resolver cache
@@ -169,14 +187,7 @@ export async function computeResolverCacheFromLockfileAsync(
169187
const contexts: Map<string, IResolverContext> = new Map();
170188
const missingOptionalDependencies: Set<string> = new Set();
171189

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('/');
190+
const isV9Lockfile: boolean = detectV9Lockfile(lockfile);
180191

181192
// Enumerate external dependencies first, to simplify looping over them for store data
182193
for (const [key, pack] of lockfile.packages) {
@@ -191,17 +202,18 @@ export async function computeResolverCacheFromLockfileAsync(
191202

192203
const integrity: string | undefined = pack.resolution?.integrity;
193204

194-
if (!name && key.startsWith('/')) {
195-
const versionIndex: number = key.indexOf('@', 2);
196-
name = key.slice(1, versionIndex);
197-
}
198-
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+
// Extract name and version from the key if not already provided
206+
let version: string | undefined;
207+
if (!key.startsWith('file:')) {
208+
const offset: number = key.startsWith('/') ? 1 : 0;
209+
const versionAtIndex: number = key.indexOf('@', offset + 1);
210+
if (versionAtIndex !== -1) {
211+
if (!name) {
212+
name = key.slice(offset, versionAtIndex);
213+
}
214+
const parenIndex: number = key.indexOf('(', versionAtIndex);
215+
version =
216+
parenIndex !== -1 ? key.slice(versionAtIndex + 1, parenIndex) : key.slice(versionAtIndex + 1);
205217
}
206218
}
207219

@@ -214,6 +226,7 @@ export async function computeResolverCacheFromLockfileAsync(
214226
descriptionFileHash: integrity,
215227
isProject: false,
216228
name,
229+
version,
217230
deps: new Map(),
218231
ordinal: -1,
219232
optional: pack.optional
@@ -222,10 +235,10 @@ export async function computeResolverCacheFromLockfileAsync(
222235
contexts.set(descriptionFileRoot, context);
223236

224237
if (pack.dependencies) {
225-
resolveDependencies(workspaceRoot, pack.dependencies, context, isV9Lockfile);
238+
resolveDependencies(workspaceRoot, pack.dependencies, context, isV9Lockfile, lockfile.packages);
226239
}
227240
if (pack.optionalDependencies) {
228-
resolveDependencies(workspaceRoot, pack.optionalDependencies, context, isV9Lockfile);
241+
resolveDependencies(workspaceRoot, pack.optionalDependencies, context, isV9Lockfile, lockfile.packages);
229242
}
230243
}
231244

@@ -266,13 +279,19 @@ export async function computeResolverCacheFromLockfileAsync(
266279
contexts.set(descriptionFileRoot, context);
267280

268281
if (importer.dependencies) {
269-
resolveDependencies(workspaceRoot, importer.dependencies, context, isV9Lockfile);
282+
resolveDependencies(workspaceRoot, importer.dependencies, context, isV9Lockfile, lockfile.packages);
270283
}
271284
if (importer.devDependencies) {
272-
resolveDependencies(workspaceRoot, importer.devDependencies, context, isV9Lockfile);
285+
resolveDependencies(workspaceRoot, importer.devDependencies, context, isV9Lockfile, lockfile.packages);
273286
}
274287
if (importer.optionalDependencies) {
275-
resolveDependencies(workspaceRoot, importer.optionalDependencies, context, isV9Lockfile);
288+
resolveDependencies(
289+
workspaceRoot,
290+
importer.optionalDependencies,
291+
context,
292+
isV9Lockfile,
293+
lockfile.packages
294+
);
276295
}
277296
}
278297

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

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ const PNPM8_MAX_LENGTH_WITHOUT_HASH: number = 120 - 26 - 1;
1313
const PNPM10_MAX_LENGTH_WITHOUT_HASH: number = 120 - 32 - 1;
1414
const BASE32: string[] = 'abcdefghijklmnopqrstuvwxyz234567'.split('');
1515

16+
const SPECIAL_CHARS_REGEX: RegExp = /[\\/:*?"<>|]/g;
17+
const HASH_CHAR_REGEX: RegExp = /#/g;
18+
const TRAILING_PAREN_REGEX: RegExp = /\)$/;
19+
const PNPM10_PARENS_REGEX: RegExp = /\)\(|\(|\)/g;
20+
const PNPM8_PARENS_REGEX: RegExp = /(\)\()|\(/g;
21+
1622
// https://github.com/swansontec/rfc4648.js/blob/ead9c9b4b68e5d4a529f32925da02c02984e772c/src/codec.ts#L82-L118
1723
export function createBase32Hash(input: string): string {
1824
const data: Buffer = createHash('md5').update(input).digest();
@@ -53,19 +59,19 @@ export function createShortSha256Hash(input: string): string {
5359

5460
// https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/packages/dependency-path/src/index.ts#L167-L189
5561
export function depPathToFilename(depPath: string, usePnpm10Hashing?: boolean): string {
56-
let filename: string = depPathToFilenameUnescaped(depPath).replace(/[\\/:*?"<>|]/g, '+');
62+
let filename: string = depPathToFilenameUnescaped(depPath).replace(SPECIAL_CHARS_REGEX, '+');
5763
if (usePnpm10Hashing) {
5864
// pnpm 10 also replaces `#` and handles parentheses differently
59-
filename = filename.replace(/#/g, '+');
65+
filename = filename.replace(HASH_CHAR_REGEX, '+');
6066
if (filename.includes('(')) {
61-
filename = filename.replace(/\)$/, '').replace(/\)\(|\(|\)/g, '_');
67+
filename = filename.replace(TRAILING_PAREN_REGEX, '').replace(PNPM10_PARENS_REGEX, '_');
6268
}
6369
if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
6470
return `${filename.substring(0, PNPM10_MAX_LENGTH_WITHOUT_HASH)}_${createShortSha256Hash(filename)}`;
6571
}
6672
} else {
6773
if (filename.includes('(')) {
68-
filename = filename.replace(/(\)\()|\(/g, '_').replace(/\)$/, '');
74+
filename = filename.replace(PNPM8_PARENS_REGEX, '_').replace(TRAILING_PAREN_REGEX, '');
6975
}
7076
if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
7177
return `${filename.substring(0, PNPM8_MAX_LENGTH_WITHOUT_HASH)}_${createBase32Hash(filename)}`;
@@ -87,25 +93,21 @@ export function resolveDependencyKey(
8793
key: string,
8894
specifier: string,
8995
context: IResolverContext,
90-
isV9Lockfile?: boolean
96+
isV9Lockfile?: boolean,
97+
packageKeys?: { has(key: string): boolean }
9198
): string {
92-
if (specifier.startsWith('/')) {
93-
return getDescriptionFileRootFromKey(lockfileFolder, specifier);
94-
} else if (specifier.startsWith('link:')) {
99+
if (specifier.startsWith('link:')) {
95100
if (context.isProject) {
96101
return path.posix.join(context.descriptionFileRoot, specifier.slice(5));
97102
} else {
98103
return path.posix.join(lockfileFolder, specifier.slice(5));
99104
}
100105
} else if (specifier.startsWith('file:')) {
101106
return getDescriptionFileRootFromKey(lockfileFolder, specifier, key);
107+
} else if (packageKeys?.has(specifier)) {
108+
// The specifier is a full package key (v6: '/pkg@ver', v9: 'pkg@ver')
109+
return getDescriptionFileRootFromKey(lockfileFolder, specifier);
102110
} else {
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-
}
109111
// Construct the full dependency key from package name and version specifier.
110112
// v6 keys use '/' prefix; v9 keys don't.
111113
const fullKey: string = isV9Lockfile ? `${key}@${specifier}` : `/${key}@${specifier}`;
@@ -124,18 +126,9 @@ export function getDescriptionFileRootFromKey(lockfileFolder: string, key: strin
124126
// Detect lockfile version: v6 keys start with '/', v9 keys don't
125127
const isV9Key: boolean = !key.startsWith('/') && !key.startsWith('file:');
126128

127-
if (!key.startsWith('file:')) {
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-
}
129+
if (!key.startsWith('file:') && !name) {
130+
const offset: number = key.startsWith('/') ? 1 : 0;
131+
name = key.slice(offset, key.indexOf('@', offset + 1));
139132
}
140133
if (!name) {
141134
throw new Error(`Missing package name for ${key}`);
@@ -150,11 +143,19 @@ export function resolveDependencies(
150143
lockfileFolder: string,
151144
collection: Record<string, IDependencyEntry>,
152145
context: IResolverContext,
153-
isV9Lockfile?: boolean
146+
isV9Lockfile?: boolean,
147+
packageKeys?: { has(key: string): boolean }
154148
): void {
155149
for (const [key, value] of Object.entries(collection)) {
156150
const version: string = typeof value === 'string' ? value : value.version;
157-
const resolved: string = resolveDependencyKey(lockfileFolder, key, version, context, isV9Lockfile);
151+
const resolved: string = resolveDependencyKey(
152+
lockfileFolder,
153+
key,
154+
version,
155+
context,
156+
isV9Lockfile,
157+
packageKeys
158+
);
158159

159160
context.deps.set(key, resolved);
160161
}

0 commit comments

Comments
 (0)