From a7be1a1f7d6bcee585a9e7e0b02e14c3db21fcf5 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:38:03 +0000 Subject: [PATCH 1/3] 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> --- ...solver-cache-pnpm-10_2026-04-07-06-16.json | 10 + .../src/afterInstallAsync.ts | 20 +- .../computeResolverCacheFromLockfileAsync.ts | 87 +++- .../rush-resolver-cache-plugin/src/helpers.ts | 117 +++--- .../src/pnpm/depPath/common.ts | 42 ++ .../src/pnpm/depPath/hash.ts | 48 +++ .../src/pnpm/depPath/v10.ts | 14 + .../src/pnpm/depPath/v8.ts | 25 ++ .../src/pnpm/depPath/v9.ts | 14 + .../src/pnpm/index.ts | 56 +++ .../src/pnpm/keys/v6.ts | 8 + .../src/pnpm/keys/v9.ts | 8 + .../src/pnpm/store/v10.ts | 39 ++ .../src/pnpm/store/v3.ts | 12 + .../src/pnpm/v10.ts | 15 + .../rush-resolver-cache-plugin/src/pnpm/v8.ts | 15 + .../rush-resolver-cache-plugin/src/pnpm/v9.ts | 17 + ...esolverCacheFromLockfileAsync.test.ts.snap | 214 ++++++++++ .../test/__snapshots__/helpers.test.ts.snap | 70 +++- ...puteResolverCacheFromLockfileAsync.test.ts | 26 +- .../src/test/helpers.test.ts | 373 +++++++++++++++++- .../rush-resolver-cache-plugin/src/types.ts | 1 + .../build-tests-subspace-v9.yaml | 161 ++++++++ 23 files changed, 1283 insertions(+), 109 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-resolver-cache-pnpm-10_2026-04-07-06-16.json create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/common.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/hash.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v10.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v8.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v9.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/index.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v6.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v9.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v3.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts create mode 100644 rush-plugins/rush-resolver-cache-plugin/test-collateral/build-tests-subspace-v9.yaml diff --git a/common/changes/@microsoft/rush/bmiddha-resolver-cache-pnpm-10_2026-04-07-06-16.json b/common/changes/@microsoft/rush/bmiddha-resolver-cache-pnpm-10_2026-04-07-06-16.json new file mode 100644 index 00000000000..813779a5a3c --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-resolver-cache-pnpm-10_2026-04-07-06-16.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "rush-resolver-cache-plugin: add pnpm 10 / lockfile v9 compatibility", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts index 9c675ca96b8..bbacb70ab4c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts @@ -16,6 +16,7 @@ import { computeResolverCacheFromLockfileAsync, type IPlatformInfo } from './computeResolverCacheFromLockfileAsync'; +import { type PnpmMajorVersion, type IPnpmVersionHelpers, getPnpmVersionHelpersAsync } from './pnpm'; import type { IResolverContext } from './types'; /** @@ -79,10 +80,19 @@ export async function afterInstallAsync( const lockFilePath: string = subspace.getCommittedShrinkwrapFilePath(variant); - const pnpmStoreDir: string = `${rushConfiguration.pnpmOptions.pnpmStorePath}/v3/files/`; + const pnpmStorePath: string = rushConfiguration.pnpmOptions.pnpmStorePath; + + const pnpmMajorVersion: PnpmMajorVersion = (() => { + const major: number = parseInt(rushConfiguration.packageManagerToolVersion, 10); + if (major >= 10) return 10; + if (major >= 9) return 9; + return 8; + })() as PnpmMajorVersion; + + const pnpmHelpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmMajorVersion); terminal.writeLine(`Using pnpm-lock from: ${lockFilePath}`); - terminal.writeLine(`Using pnpm store folder: ${pnpmStoreDir}`); + terminal.writeLine(`Using pnpm ${pnpmMajorVersion} store at: ${pnpmStorePath}`); const workspaceRoot: string = subspace.getSubspaceTempFolderPath(); const cacheFilePath: string = `${workspaceRoot}/resolver-cache.json`; @@ -166,10 +176,7 @@ export async function afterInstallAsync( const prefixIndex: number = descriptionFileHash.indexOf('-'); const hash: string = Buffer.from(descriptionFileHash.slice(prefixIndex + 1), 'base64').toString('hex'); - // The pnpm store directory has index files of package contents at paths: - // /v3/files//-index.json - // See https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/store/cafs/src/getFilePathInCafs.ts#L33 - const indexPath: string = `${pnpmStoreDir}${hash.slice(0, 2)}/${hash.slice(2)}-index.json`; + const indexPath: string = pnpmHelpers.getStoreIndexPath(pnpmStorePath, context, hash); try { const indexContent: string = await FileSystem.readFileAsync(indexPath); @@ -254,6 +261,7 @@ export async function afterInstallAsync( platformInfo: getPlatformInfo(), projectByImporterPath, lockfile: lockFile, + pnpmVersion: pnpmMajorVersion, afterExternalPackagesAsync }); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts index e0aff3acbf3..7e19e7dee9c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts @@ -9,7 +9,13 @@ import type { } from '@rushstack/webpack-workspace-resolve-plugin'; import type { PnpmShrinkwrapFile } from './externals'; -import { getDescriptionFileRootFromKey, resolveDependencies, createContextSerializer } from './helpers'; +import { + getDescriptionFileRootFromKey, + resolveDependencies, + createContextSerializer, + extractNameAndVersionFromKey +} from './helpers'; +import { type PnpmMajorVersion, type IPnpmVersionHelpers, getPnpmVersionHelpersAsync } from './pnpm'; import type { IResolverContext } from './types'; /** @@ -105,6 +111,9 @@ function extractBundledDependencies( } } +// Re-export for downstream consumers +export type { PnpmMajorVersion, IPnpmVersionHelpers } from './pnpm'; + /** * Options for computing the resolver cache from a lockfile. */ @@ -129,6 +138,13 @@ export interface IComputeResolverCacheFromLockfileOptions { * The lockfile to compute the cache from */ lockfile: PnpmShrinkwrapFile; + /** + * The major version of pnpm configured in rush.json (e.g. `"10.27.0"` → 10). + * Used to select the correct dep-path hashing algorithm and store layout. + * When omitted, the version is inferred from the lockfile format (v6 → pnpm 8, + * v9 → pnpm 9). + */ + pnpmVersion?: PnpmMajorVersion; /** * A callback to process external packages after they have been enumerated. * Broken out as a separate function to facilitate testing without hitting the disk. @@ -152,6 +168,44 @@ function convertToSlashes(path: string): string { return path.replace(/\\/g, '/'); } +/** + * Detects the pnpm major version from the lockfile format and an optional + * caller-supplied version (derived from rush.json `pnpmVersion`). + * + * @param lockfile - The parsed shrinkwrap / lockfile + * @param configuredPnpmVersion - The pnpm major version from rush.json, if available. + * When provided this takes precedence, because the lockfile alone cannot distinguish + * pnpm 9 from pnpm 10 (both use lockfile v9). + */ +export function detectPnpmMajorVersion( + lockfile: PnpmShrinkwrapFile, + configuredPnpmVersion?: PnpmMajorVersion +): PnpmMajorVersion { + if (configuredPnpmVersion !== undefined) { + return configuredPnpmVersion; + } + + // Detect from lockfile version + if (lockfile.shrinkwrapFileMajorVersion >= 9) { + // Lockfile v9 is shared by pnpm 9 and pnpm 10. + // Without the configured version we cannot tell them apart; default to 9 + // (v8 dep-path algorithm, v3 store, v9 key format). + return 9; + } + + if (lockfile.shrinkwrapFileMajorVersion > 0) { + return 8; + } + + // Fallback for lockfiles where version parsing failed: inspect the first non-file package key. + for (const key of lockfile.packages.keys()) { + if (!key.startsWith('file:')) { + return key.startsWith('/') ? 8 : 9; + } + } + return 8; +} + /** * Given a lockfile and information about the workspace and platform, computes the resolver cache file. * @param params - The options for computing the resolver cache @@ -169,10 +223,19 @@ export async function computeResolverCacheFromLockfileAsync( const contexts: Map = new Map(); const missingOptionalDependencies: Set = new Set(); + const pnpmVersion: PnpmMajorVersion = detectPnpmMajorVersion(lockfile, params.pnpmVersion); + + const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmVersion); + // Enumerate external dependencies first, to simplify looping over them for store data for (const [key, pack] of lockfile.packages) { let name: string | undefined = pack.name; - const descriptionFileRoot: string = getDescriptionFileRootFromKey(workspaceRoot, key, name); + const descriptionFileRoot: string = getDescriptionFileRootFromKey( + workspaceRoot, + key, + helpers.depPathToFilename, + name + ); // Skip optional dependencies that are incompatible with the current environment if (pack.optional && !isPackageCompatible(pack, platformInfo)) { @@ -182,9 +245,12 @@ export async function computeResolverCacheFromLockfileAsync( const integrity: string | undefined = pack.resolution?.integrity; - if (!name && key.startsWith('/')) { - const versionIndex: number = key.indexOf('@', 2); - name = key.slice(1, versionIndex); + // Extract name and version from the key if not already provided + const parsed: { name: string; version: string } | undefined = extractNameAndVersionFromKey(key); + if (parsed) { + if (!name) { + name = parsed.name; + } } if (!name) { @@ -196,6 +262,7 @@ export async function computeResolverCacheFromLockfileAsync( descriptionFileHash: integrity, isProject: false, name, + version: parsed?.version, deps: new Map(), ordinal: -1, optional: pack.optional @@ -204,10 +271,10 @@ export async function computeResolverCacheFromLockfileAsync( contexts.set(descriptionFileRoot, context); if (pack.dependencies) { - resolveDependencies(workspaceRoot, pack.dependencies, context); + resolveDependencies(workspaceRoot, pack.dependencies, context, helpers, lockfile.packages); } if (pack.optionalDependencies) { - resolveDependencies(workspaceRoot, pack.optionalDependencies, context); + resolveDependencies(workspaceRoot, pack.optionalDependencies, context, helpers, lockfile.packages); } } @@ -248,13 +315,13 @@ export async function computeResolverCacheFromLockfileAsync( contexts.set(descriptionFileRoot, context); if (importer.dependencies) { - resolveDependencies(workspaceRoot, importer.dependencies, context); + resolveDependencies(workspaceRoot, importer.dependencies, context, helpers, lockfile.packages); } if (importer.devDependencies) { - resolveDependencies(workspaceRoot, importer.devDependencies, context); + resolveDependencies(workspaceRoot, importer.devDependencies, context, helpers, lockfile.packages); } if (importer.optionalDependencies) { - resolveDependencies(workspaceRoot, importer.optionalDependencies, context); + resolveDependencies(workspaceRoot, importer.optionalDependencies, context, helpers, lockfile.packages); } } diff --git a/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts b/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts index 99ade3da188..cacc5449ed5 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts @@ -1,58 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { createHash } from 'node:crypto'; import * as path from 'node:path'; import type { ISerializedResolveContext } from '@rushstack/webpack-workspace-resolve-plugin'; import type { IDependencyEntry, IResolverContext } from './types'; - -const MAX_LENGTH_WITHOUT_HASH: number = 120 - 26 - 1; -const BASE32: string[] = 'abcdefghijklmnopqrstuvwxyz234567'.split(''); - -// https://github.com/swansontec/rfc4648.js/blob/ead9c9b4b68e5d4a529f32925da02c02984e772c/src/codec.ts#L82-L118 -export function createBase32Hash(input: string): string { - const data: Buffer = createHash('md5').update(input).digest(); - - const mask: 0x1f = 0x1f; - let out: string = ''; - - let bits: number = 0; // Number of bits currently in the buffer - let buffer: number = 0; // Bits waiting to be written out, MSB first - for (let i: number = 0; i < data.length; ++i) { - // eslint-disable-next-line no-bitwise - buffer = (buffer << 8) | (0xff & data[i]); - bits += 8; - - // Write out as much as we can: - while (bits > 5) { - bits -= 5; - // eslint-disable-next-line no-bitwise - out += BASE32[mask & (buffer >> bits)]; - } - } - - // Partial character: - if (bits) { - // eslint-disable-next-line no-bitwise - out += BASE32[mask & (buffer << (5 - bits))]; - } - - return out; -} - -// https://github.com/pnpm/pnpm/blob/f394cfccda7bc519ceee8c33fc9b68a0f4235532/packages/dependency-path/src/index.ts#L167-L189 -export function depPathToFilename(depPath: string): string { - let filename: string = depPathToFilenameUnescaped(depPath).replace(/[\\/:*?"<>|]/g, '+'); - if (filename.includes('(')) { - filename = filename.replace(/(\)\()|\(/g, '_').replace(/\)$/, ''); - } - if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) { - return `${filename.substring(0, MAX_LENGTH_WITHOUT_HASH)}_${createBase32Hash(filename)}`; - } - return filename; -} +import type { IPnpmVersionHelpers } from './pnpm'; /** * Computes the root folder for a dependency from a reference to it in another package @@ -60,26 +14,31 @@ export function depPathToFilename(depPath: string): string { * @param key - The key of the dependency * @param specifier - The specifier in the lockfile for the dependency * @param context - The owning package + * @param helpers - Version-specific pnpm helpers * @returns The identifier for the dependency */ export function resolveDependencyKey( lockfileFolder: string, key: string, specifier: string, - context: IResolverContext + context: IResolverContext, + helpers: IPnpmVersionHelpers, + packageKeys?: { has(key: string): boolean } ): string { - if (specifier.startsWith('/')) { - return getDescriptionFileRootFromKey(lockfileFolder, specifier); - } else if (specifier.startsWith('link:')) { + if (specifier.startsWith('link:')) { if (context.isProject) { return path.posix.join(context.descriptionFileRoot, specifier.slice(5)); } else { return path.posix.join(lockfileFolder, specifier.slice(5)); } } else if (specifier.startsWith('file:')) { - return getDescriptionFileRootFromKey(lockfileFolder, specifier, key); + return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename, key); + } else if (packageKeys?.has(specifier)) { + // The specifier is a full package key (v6: '/pkg@ver', v9: 'pkg@ver') + return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename); } else { - return getDescriptionFileRootFromKey(lockfileFolder, `/${key}@${specifier}`); + const fullKey: string = helpers.buildDependencyKey(key, specifier); + return getDescriptionFileRootFromKey(lockfileFolder, fullKey, helpers.depPathToFilename); } } @@ -87,12 +46,19 @@ export function resolveDependencyKey( * Computes the physical path to a dependency based on its entry * @param lockfileFolder - The folder that contains the lockfile during installation * @param key - The key of the dependency + * @param depPathToFilename - Version-specific function to convert dep paths to filenames * @param name - The name of the dependency, if provided * @returns The physical path to the dependency */ -export function getDescriptionFileRootFromKey(lockfileFolder: string, key: string, name?: string): string { - if (!key.startsWith('file:')) { - name = key.slice(1, key.indexOf('@', 2)); +export function getDescriptionFileRootFromKey( + lockfileFolder: string, + key: string, + depPathToFilename: (depPath: string) => string, + name?: string +): string { + if (!key.startsWith('file:') && !name) { + const offset: number = key.startsWith('/') ? 1 : 0; + name = key.slice(offset, key.indexOf('@', offset + 1)); } if (!name) { throw new Error(`Missing package name for ${key}`); @@ -106,29 +72,44 @@ export function getDescriptionFileRootFromKey(lockfileFolder: string, key: strin export function resolveDependencies( lockfileFolder: string, collection: Record, - context: IResolverContext + context: IResolverContext, + helpers: IPnpmVersionHelpers, + packageKeys?: { has(key: string): boolean } ): void { for (const [key, value] of Object.entries(collection)) { const version: string = typeof value === 'string' ? value : value.version; - const resolved: string = resolveDependencyKey(lockfileFolder, key, version, context); + const resolved: string = resolveDependencyKey( + lockfileFolder, + key, + version, + context, + helpers, + packageKeys + ); context.deps.set(key, resolved); } } /** - * - * @param depPath - The path to the dependency - * @returns The folder name for the dependency + * Extracts the package name and version from a lockfile package key. + * @param key - The lockfile package key (e.g. '/autoprefixer\@9.8.8', '\@scope/name\@1.0.0(peer\@2.0.0)') + * @returns The extracted name and version, or undefined for file: keys */ -export function depPathToFilenameUnescaped(depPath: string): string { - if (depPath.indexOf('file:') !== 0) { - if (depPath.startsWith('/')) { - depPath = depPath.slice(1); - } - return depPath; +export function extractNameAndVersionFromKey(key: string): { name: string; version: string } | undefined { + if (key.startsWith('file:')) { + return undefined; + } + const offset: number = key.startsWith('/') ? 1 : 0; + const versionAtIndex: number = key.indexOf('@', offset + 1); + if (versionAtIndex === -1) { + return undefined; } - return depPath.replace(':', '+'); + const name: string = key.slice(offset, versionAtIndex); + const parenIndex: number = key.indexOf('(', versionAtIndex); + const version: string = + parenIndex !== -1 ? key.slice(versionAtIndex + 1, parenIndex) : key.slice(versionAtIndex + 1); + return { name, version }; } /** diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/common.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/common.ts new file mode 100644 index 00000000000..a50b05a22c6 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/common.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Shared logic for pnpm 9+ depPathToFilename implementations. +// The depPathToFilenameUnescaped function and overall depPathToFilename structure +// are identical between pnpm 9 and 10; only the hash function, hash length, and +// special-character regex differ. + +const TRAILING_PAREN_REGEX: RegExp = /\)$/; +const PARENS_REGEX: RegExp = /\)\(|\(|\)/g; + +export function depPathToFilenameUnescaped(depPath: string): string { + if (depPath.indexOf('file:') !== 0) { + if (depPath[0] === '/') { + depPath = depPath.substring(1); + } + const index: number = depPath.indexOf('@', 1); + if (index === -1) return depPath; + return `${depPath.substring(0, index)}@${depPath.slice(index + 1)}`; + } + return depPath.replace(':', '+'); +} + +export interface IDepPathToFilenameOptions { + specialCharsRegex: RegExp; + maxLengthWithoutHash: number; + hashFn: (input: string) => string; +} + +export function createDepPathToFilename(options: IDepPathToFilenameOptions): (depPath: string) => string { + const { specialCharsRegex, maxLengthWithoutHash, hashFn } = options; + return (depPath: string): string => { + let filename: string = depPathToFilenameUnescaped(depPath).replace(specialCharsRegex, '+'); + if (filename.includes('(')) { + filename = filename.replace(TRAILING_PAREN_REGEX, '').replace(PARENS_REGEX, '_'); + } + if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) { + return `${filename.substring(0, maxLengthWithoutHash)}_${hashFn(filename)}`; + } + return filename; + }; +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/hash.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/hash.ts new file mode 100644 index 00000000000..db32bcfb575 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/hash.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Hash functions vendored from pnpm to avoid transitive dependencies. +// +// createBase32Hash (MD5 base32, used by pnpm 8 and 9): +// https://github.com/pnpm/pnpm/blob/afe8ecef1f24812845b699c141d52643d1524079/packages/crypto.base32-hash/src/index.ts +// base32 encoding (from rfc4648): +// https://github.com/swansontec/rfc4648.js/blob/ead9c9b4b68e5d4a529f32925da02c02984e772c/src/codec.ts#L82-L118 +// +// createShortSha256Hash (SHA-256 hex truncated to 32 chars, used by pnpm 10): +// https://github.com/pnpm/pnpm/blob/42ecf04fd0e442af8610ae4231855e004732dbf7/crypto/hash/src/index.ts + +import { createHash } from 'node:crypto'; + +const BASE32: string[] = 'abcdefghijklmnopqrstuvwxyz234567'.split(''); + +export function createBase32Hash(input: string): string { + const data: Buffer = createHash('md5').update(input).digest(); + + const mask: 0x1f = 0x1f; + let out: string = ''; + + let bits: number = 0; + let buffer: number = 0; + for (let i: number = 0; i < data.length; ++i) { + // eslint-disable-next-line no-bitwise + buffer = (buffer << 8) | (0xff & data[i]); + bits += 8; + + while (bits > 5) { + bits -= 5; + // eslint-disable-next-line no-bitwise + out += BASE32[mask & (buffer >> bits)]; + } + } + + if (bits) { + // eslint-disable-next-line no-bitwise + out += BASE32[mask & (buffer << (5 - bits))]; + } + + return out; +} + +export function createShortSha256Hash(input: string): string { + return createHash('sha256').update(input).digest('hex').substring(0, 32); +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v10.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v10.ts new file mode 100644 index 00000000000..36215f05124 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v10.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Vendored from \@pnpm/dependency-path\@1000.0.1 (pnpm v10.0.0). +// https://github.com/pnpm/pnpm/blob/42ecf04fd0e442af8610ae4231855e004732dbf7/packages/dependency-path/src/index.ts + +import { createDepPathToFilename } from './common'; +import { createShortSha256Hash } from './hash'; + +export const depPathToFilename: (depPath: string) => string = createDepPathToFilename({ + specialCharsRegex: /[\\/:*?"<>|#]/g, + maxLengthWithoutHash: 120 - 32 - 1, + hashFn: createShortSha256Hash +}); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v8.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v8.ts new file mode 100644 index 00000000000..e11814f318c --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v8.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Based on \@pnpm/dependency-path\@2.1.8 (pnpm v8.15.9). +// https://github.com/pnpm/pnpm/blob/afe8ecef1f24812845b699c141d52643d1524079/packages/dependency-path/src/index.ts +// +// NOTE: The original pnpm source's depPathToFilenameUnescaped uses lastIndexOf('/') +// to find the version separator. This works for pnpm's internal dep paths which use '/' +// before the version (e.g. /@babel/code-frame/7.24.2), but NOT for lockfile v6 keys +// which use '@' (e.g. /@babel/code-frame@7.24.2). For scoped packages, lastIndexOf('/') +// would find the scope separator and produce @babel@code-frame@7.24.2 instead of the +// correct @babel+code-frame@7.24.2 (where the scope '/' is replaced by '+' via regex). +// +// We use the shared depPathToFilenameUnescaped (indexOf('@', 1)) from common.ts which +// correctly handles lockfile key format. The hash algorithm and special-char regex are +// identical to pnpm 9. + +import { createDepPathToFilename } from './common'; +import { createBase32Hash } from './hash'; + +export const depPathToFilename: (depPath: string) => string = createDepPathToFilename({ + specialCharsRegex: /[\\/:*?"<>|]/g, + maxLengthWithoutHash: 120 - 26 - 1, + hashFn: createBase32Hash +}); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v9.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v9.ts new file mode 100644 index 00000000000..746c2bf9cc7 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/depPath/v9.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Vendored from \@pnpm/dependency-path\@900.0.0 (pnpm v9.15.9). +// https://github.com/pnpm/pnpm/blob/d22a3f65ee047ecee7c89dd6f1971ecea4ecd4d4/packages/dependency-path/src/index.ts + +import { createDepPathToFilename } from './common'; +import { createBase32Hash } from './hash'; + +export const depPathToFilename: (depPath: string) => string = createDepPathToFilename({ + specialCharsRegex: /[\\/:*?"<>|]/g, + maxLengthWithoutHash: 120 - 26 - 1, + hashFn: createBase32Hash +}); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/index.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/index.ts new file mode 100644 index 00000000000..5d5e14d812c --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/index.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IResolverContext } from '../types'; + +/** + * The major version of pnpm being used. Each version uses a different lockfile format + * and store layout: + * - pnpm 8: lockfile v6, store v3, MD5 base32 hash for dep paths + * - pnpm 9: lockfile v9, store v3, MD5 base32 hash for dep paths + * - pnpm 10: lockfile v9, store v10, SHA-256 hex hash for dep paths + */ +export type PnpmMajorVersion = 8 | 9 | 10; + +/** + * Version-specific helpers for resolving pnpm dependency paths, lockfile keys, + * and store index paths. Each pnpm major version has its own implementation. + */ +export interface IPnpmVersionHelpers { + /** + * Converts a pnpm dependency path to its on-disk folder name. + * Uses MD5 base32 hashing for pnpm 8/9 and SHA-256 hex hashing for pnpm 10. + */ + depPathToFilename(depPath: string): string; + + /** + * Constructs the full lockfile package key from a package name and version specifier. + * pnpm 8 uses `/{name}\@{specifier}` (v6 key format); pnpm 9/10 use `{name}\@{specifier}` (v9 key format). + */ + buildDependencyKey(name: string, specifier: string): string; + + /** + * Computes the pnpm store index file path for a given package integrity hash. + * @param pnpmStorePath - The root pnpm store path (e.g. `~/.local/share/pnpm/store`) + * @param context - The resolver context for the package (provides name/version for v10 paths) + * @param hash - The hex-encoded integrity hash + */ + getStoreIndexPath(pnpmStorePath: string, context: IResolverContext, hash: string): string; +} + +/** + * Loads the version-specific pnpm helpers for the given major version. + * Uses async imports so that only the needed version's code is loaded. + */ +export async function getPnpmVersionHelpersAsync(version: PnpmMajorVersion): Promise { + switch (version) { + case 8: + return (await import('./v8')).helpers; + case 9: + return (await import('./v9')).helpers; + case 10: + return (await import('./v10')).helpers; + default: + throw new Error(`Unsupported pnpm major version: ${version}`); + } +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v6.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v6.ts new file mode 100644 index 00000000000..c6c4882fe3a --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v6.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Lockfile v6 key format (used by pnpm 8): keys are prefixed with '/'. + +export function buildDependencyKey(name: string, specifier: string): string { + return `/${name}@${specifier}`; +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v9.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v9.ts new file mode 100644 index 00000000000..4e9d934f46c --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/keys/v9.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Lockfile v9 key format (used by pnpm 9 and 10): keys have no leading '/'. + +export function buildDependencyKey(name: string, specifier: string): string { + return `${name}@${specifier}`; +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts new file mode 100644 index 00000000000..959907fac75 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Store v10 index path format (used by pnpm 10): +// {storeDir}/v10/index/{hash[0:2]}/{hash[2:64]}-{name}@{version}.json +// Falls back to directory scan when the primary path doesn't exist. + +import { existsSync, readdirSync } from 'node:fs'; + +import type { IResolverContext } from '../../types'; + +export function getStoreIndexPath(pnpmStorePath: string, context: IResolverContext, hash: string): string { + // pnpm 10 truncates integrity hashes to 32 bytes (64 hex chars) for index paths. + const truncHash: string = hash.length > 64 ? hash.slice(0, 64) : hash; + const hashDir: string = truncHash.slice(0, 2); + const hashRest: string = truncHash.slice(2); + // pnpm 10 index path format: /-@.json + const pkgName: string = (context.name || '').replace(/\//g, '+'); + const nameVer: string = context.version ? `${pkgName}@${context.version}` : pkgName; + let indexPath: string = `${pnpmStorePath}/v10/index/${hashDir}/${hashRest}-${nameVer}.json`; + // For truncated/hashed folder names, nameVer from the key may be wrong. + // Fallback: scan the directory for a file matching the hash prefix. + if (!existsSync(indexPath)) { + const dir: string = `${pnpmStorePath}/v10/index/${hashDir}/`; + const filePrefix: string = `${hashRest}-`; + try { + const entries: import('node:fs').Dirent[] = readdirSync(dir, { withFileTypes: true }); + const match: import('node:fs').Dirent | undefined = entries.find( + (e) => e.isFile() && e.name.startsWith(filePrefix) + ); + if (match) { + indexPath = dir + match.name; + } + } catch { + // ignore + } + } + return indexPath; +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v3.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v3.ts new file mode 100644 index 00000000000..f7cfc382095 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v3.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Store v3 index path format (used by pnpm 8 and 9): +// {storeDir}/v3/files/{hash[0:2]}/{hash[2:]}-index.json + +import type { IResolverContext } from '../../types'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function getStoreIndexPath(pnpmStorePath: string, _context: IResolverContext, hash: string): string { + return `${pnpmStorePath}/v3/files/${hash.slice(0, 2)}/${hash.slice(2)}-index.json`; +} diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts new file mode 100644 index 00000000000..3806a5477c3 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// pnpm 10: lockfile v9 (keys have no leading '/'), store v10, SHA-256 hex hash + +import type { IPnpmVersionHelpers } from '.'; +import { depPathToFilename } from './depPath/v10'; +import { buildDependencyKey } from './keys/v9'; +import { getStoreIndexPath } from './store/v10'; + +export const helpers: IPnpmVersionHelpers = { + depPathToFilename, + buildDependencyKey, + getStoreIndexPath +}; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts new file mode 100644 index 00000000000..a57f5a0317c --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// pnpm 8: lockfile v6 (keys start with '/'), store v3, MD5 base32 hash + +import type { IPnpmVersionHelpers } from '.'; +import { depPathToFilename } from './depPath/v8'; +import { buildDependencyKey } from './keys/v6'; +import { getStoreIndexPath } from './store/v3'; + +export const helpers: IPnpmVersionHelpers = { + depPathToFilename, + buildDependencyKey, + getStoreIndexPath +}; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts new file mode 100644 index 00000000000..7cb2845b017 --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// pnpm 9: lockfile v9 (keys have no leading '/'), store v3, MD5 base32 hash + +import type { IPnpmVersionHelpers } from '.'; +// pnpm 9 uses the same dep-path hashing algorithm as pnpm 8 (MD5 base32) +// but a different depPathToFilenameUnescaped (indexOf('@') vs lastIndexOf('/')) +import { depPathToFilename } from './depPath/v9'; +import { buildDependencyKey } from './keys/v9'; +import { getStoreIndexPath } from './store/v3'; + +export const helpers: IPnpmVersionHelpers = { + depPathToFilename, + buildDependencyKey, + getStoreIndexPath +}; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap b/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap index 401d0b21317..0e029510138 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap @@ -6392,6 +6392,220 @@ Object { } `; +exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: build-tests-subspace-v9.yaml (pnpm 9) 1`] = ` +Object { + "basePath": "/$root/", + "contexts": Array [ + Object { + "deps": Object { + "browserslist": 1, + }, + "name": "autoprefixer", + "root": "common/temp/build-tests/node_modules/.pnpm/autoprefixer@9.8.8/node_modules/autoprefixer", + }, + Object { + "name": "browserslist", + "root": "common/temp/build-tests/node_modules/.pnpm/browserslist@4.22.1/node_modules/browserslist", + }, + Object { + "deps": Object { + "@babel/highlight": 3, + "picocolors": 4, + }, + "name": "@babel/code-frame", + "root": "common/temp/build-tests/node_modules/.pnpm/@babel+code-frame@7.24.2/node_modules/@babel/code-frame", + }, + Object { + "name": "@babel/highlight", + "root": "common/temp/build-tests/node_modules/.pnpm/@babel+highlight@7.24.5/node_modules/@babel/highlight", + }, + Object { + "name": "picocolors", + "root": "common/temp/build-tests/node_modules/.pnpm/picocolors@1.0.1/node_modules/picocolors", + }, + Object { + "name": "typescript", + "root": "common/temp/build-tests/node_modules/.pnpm/typescript@5.4.5/node_modules/typescript", + }, + Object { + "deps": Object { + "typescript": 5, + }, + "name": "@scope/bar", + "root": "common/temp/build-tests/node_modules/.pnpm/@scope+bar@2.0.0_typescript@5.4.5/node_modules/@scope/bar", + }, + Object { + "deps": Object { + "@azure/msal-browser": 8, + "@azure/msal-common": 9, + "@fluentui/merge-styles": 10, + "@fluentui/react": 11, + "react": 13, + "react-dom": 12, + }, + "name": "@some/package", + "root": "common/temp/build-tests/node_modules/.pnpm/@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0_@fluentui+merge-style_dvlowrxgl5etiz7ddf7v4yxvgm/node_modules/@some/package", + }, + Object { + "name": "@azure/msal-browser", + "root": "common/temp/build-tests/node_modules/.pnpm/@azure+msal-browser@2.28.1/node_modules/@azure/msal-browser", + }, + Object { + "name": "@azure/msal-common", + "root": "common/temp/build-tests/node_modules/.pnpm/@azure+msal-common@6.4.0/node_modules/@azure/msal-common", + }, + Object { + "name": "@fluentui/merge-styles", + "root": "common/temp/build-tests/node_modules/.pnpm/@fluentui+merge-styles@8.6.2/node_modules/@fluentui/merge-styles", + }, + Object { + "deps": Object { + "react": 13, + "react-dom": 12, + }, + "name": "@fluentui/react", + "root": "common/temp/build-tests/node_modules/.pnpm/@fluentui+react@8.117.5_react-dom@17.0.1_react@17.0.1/node_modules/@fluentui/react", + }, + Object { + "deps": Object { + "react": 13, + }, + "name": "react-dom", + "root": "common/temp/build-tests/node_modules/.pnpm/react-dom@17.0.1_react@17.0.1/node_modules/react-dom", + }, + Object { + "name": "react", + "root": "common/temp/build-tests/node_modules/.pnpm/react@17.0.1/node_modules/react", + }, + Object { + "deps": Object { + "@babel/code-frame": 2, + "autoprefixer": 0, + "typescript": 5, + }, + "name": "@local/build-tests-subspace+rush-lib-test", + "root": "build-tests-subspace/rush-lib-test", + }, + Object { + "deps": Object { + "@scope/bar": 6, + "@some/package": 7, + "typescript": 5, + }, + "name": "@local/build-tests-subspace+rush-sdk-test", + "root": "build-tests-subspace/rush-sdk-test", + }, + ], +} +`; + +exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: build-tests-subspace-v9.yaml (pnpm 10) 1`] = ` +Object { + "basePath": "/$root/", + "contexts": Array [ + Object { + "deps": Object { + "browserslist": 1, + }, + "name": "autoprefixer", + "root": "common/temp/build-tests/node_modules/.pnpm/autoprefixer@9.8.8/node_modules/autoprefixer", + }, + Object { + "name": "browserslist", + "root": "common/temp/build-tests/node_modules/.pnpm/browserslist@4.22.1/node_modules/browserslist", + }, + Object { + "deps": Object { + "@babel/highlight": 3, + "picocolors": 4, + }, + "name": "@babel/code-frame", + "root": "common/temp/build-tests/node_modules/.pnpm/@babel+code-frame@7.24.2/node_modules/@babel/code-frame", + }, + Object { + "name": "@babel/highlight", + "root": "common/temp/build-tests/node_modules/.pnpm/@babel+highlight@7.24.5/node_modules/@babel/highlight", + }, + Object { + "name": "picocolors", + "root": "common/temp/build-tests/node_modules/.pnpm/picocolors@1.0.1/node_modules/picocolors", + }, + Object { + "name": "typescript", + "root": "common/temp/build-tests/node_modules/.pnpm/typescript@5.4.5/node_modules/typescript", + }, + Object { + "deps": Object { + "typescript": 5, + }, + "name": "@scope/bar", + "root": "common/temp/build-tests/node_modules/.pnpm/@scope+bar@2.0.0_typescript@5.4.5/node_modules/@scope/bar", + }, + Object { + "deps": Object { + "@azure/msal-browser": 8, + "@azure/msal-common": 9, + "@fluentui/merge-styles": 10, + "@fluentui/react": 11, + "react": 13, + "react-dom": 12, + }, + "name": "@some/package", + "root": "common/temp/build-tests/node_modules/.pnpm/@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0_@fluentui+merge_b3ef11fe8e724aa2061fb1a9adef96be/node_modules/@some/package", + }, + Object { + "name": "@azure/msal-browser", + "root": "common/temp/build-tests/node_modules/.pnpm/@azure+msal-browser@2.28.1/node_modules/@azure/msal-browser", + }, + Object { + "name": "@azure/msal-common", + "root": "common/temp/build-tests/node_modules/.pnpm/@azure+msal-common@6.4.0/node_modules/@azure/msal-common", + }, + Object { + "name": "@fluentui/merge-styles", + "root": "common/temp/build-tests/node_modules/.pnpm/@fluentui+merge-styles@8.6.2/node_modules/@fluentui/merge-styles", + }, + Object { + "deps": Object { + "react": 13, + "react-dom": 12, + }, + "name": "@fluentui/react", + "root": "common/temp/build-tests/node_modules/.pnpm/@fluentui+react@8.117.5_react-dom@17.0.1_react@17.0.1/node_modules/@fluentui/react", + }, + Object { + "deps": Object { + "react": 13, + }, + "name": "react-dom", + "root": "common/temp/build-tests/node_modules/.pnpm/react-dom@17.0.1_react@17.0.1/node_modules/react-dom", + }, + Object { + "name": "react", + "root": "common/temp/build-tests/node_modules/.pnpm/react@17.0.1/node_modules/react", + }, + Object { + "deps": Object { + "@babel/code-frame": 2, + "autoprefixer": 0, + "typescript": 5, + }, + "name": "@local/build-tests-subspace+rush-lib-test", + "root": "build-tests-subspace/rush-lib-test", + }, + Object { + "deps": Object { + "@scope/bar": 6, + "@some/package": 7, + "typescript": 5, + }, + "name": "@local/build-tests-subspace+rush-sdk-test", + "root": "build-tests-subspace/rush-sdk-test", + }, + ], +} +`; + exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: bundled-dependencies.yaml 1`] = ` Object { "basePath": "/$root/", diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/helpers.test.ts.snap b/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/helpers.test.ts.snap index 7c4d7cb6eb6..bf2f287b195 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/helpers.test.ts.snap +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/helpers.test.ts.snap @@ -6,34 +6,72 @@ exports[`createBase32Hash hashes: a 1`] = `"btaxlooa6g3kqmodthrgs5zgme"`; exports[`createBase32Hash hashes: abracadabra 1`] = `"5rjiprc7bzyoyiwvf2f4x3vwia"`; -exports[`depPathToFilename formats: /@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)(@fluentui/merge-styles@8.6.2)(@fluentui/react@8.117.5)(@fluentui/theme@2.6.45)(@fluentui/utilities@8.15.2)(chart.js@2.9.4)(lodash@4.17.21)(moment@2.29.4)(prop-types@15.8.1)(react-dnd-html5-backend@14.1.0)(react-dnd@14.0.5)(react-dom@17.0.1)(react-intersection-observer@8.34.0)(react@17.0.1) 1`] = `"@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0_@fluentui+merge-style_yt7yh6tpppbzu7nx3lzx3f3ife"`; +exports[`createShortSha256Hash hashes: (eslint@8.57.0)(typescript@5.4.5) 1`] = `"395951816c5613fa894c6f81441c9d08"`; -exports[`depPathToFilename formats: /@storybook/core@6.5.15(@storybook/builder-webpack5@6.5.15)(@storybook/manager-webpack5@6.5.15)(eslint@8.57.0)(react-dom@17.0.1)(react@17.0.1)(typescript@5.3.3)(webpack@5.88.1) 1`] = `"@storybook+core@6.5.15_@storybook+builder-webpack5@6.5.15_@storybook+manager-webpack5@6.5.15__wnqw6tyxzeiksxiymflshxscdq"`; +exports[`createShortSha256Hash hashes: a 1`] = `"ca978112ca1bbdcafac231b39a23dc4d"`; -exports[`depPathToFilename formats: /@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2) 1`] = `"@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2"`; +exports[`createShortSha256Hash hashes: abracadabra 1`] = `"045babdcd2118960e8c8b8e0ecf65b73"`; -exports[`depPathToFilename formats: /autoprefixer@9.8.8 1`] = `"autoprefixer@9.8.8"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: /@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)(@fluentui/merge-styles@8.6.2)(@fluentui/react@8.117.5)(@fluentui/theme@2.6.45)(@fluentui/utilities@8.15.2)(chart.js@2.9.4)(lodash@4.17.21)(moment@2.29.4)(prop-types@15.8.1)(react-dnd-html5-backend@14.1.0)(react-dnd@14.0.5)(react-dom@17.0.1)(react-intersection-observer@8.34.0)(react@17.0.1) 1`] = `"@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0_@fluentui+merge-style_yt7yh6tpppbzu7nx3lzx3f3ife"`; -exports[`depPathToFilename formats: /autoprefixer@10.4.18(postcss@8.4.36) 1`] = `"autoprefixer@10.4.18_postcss@8.4.36"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: /@storybook/core@6.5.15(@storybook/builder-webpack5@6.5.15)(@storybook/manager-webpack5@6.5.15)(eslint@8.57.0)(react-dom@17.0.1)(react@17.0.1)(typescript@5.3.3)(webpack@5.88.1) 1`] = `"@storybook+core@6.5.15_@storybook+builder-webpack5@6.5.15_@storybook+manager-webpack5@6.5.15__wnqw6tyxzeiksxiymflshxscdq"`; -exports[`depPathToFilename formats: /react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2) 1`] = `"react-transition-group@4.4.5_react-dom@17.0.2_react@17.0.2"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: /@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2) 1`] = `"@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2"`; -exports[`depPathToFilename formats: file:../../../libraries/ts-command-line(@types/node@18.17.15) 1`] = `"file+..+..+..+libraries+ts-command-line_@types+node@18.17.15"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: /autoprefixer@9.8.8 1`] = `"autoprefixer@9.8.8"`; -exports[`depPathToFilename formats: file:../../../rigs/local-node-rig 1`] = `"file+..+..+..+rigs+local-node-rig"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: /autoprefixer@10.4.18(postcss@8.4.36) 1`] = `"autoprefixer@10.4.18_postcss@8.4.36"`; -exports[`getDescriptionFileRootFromKey parses: "/@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)(@fluentui/merge-styles@8.6.2)(@fluentui/react@8.117.5)(@fluentui/theme@2.6.45)(@fluentui/utilities@8.15.2)(chart.js@2.9.4)(lodash@4.17.21)(moment@2.29.4)(prop-types@15.8.1)(react-dnd-html5-backend@14.1.0)(react-dnd@14.0.5)(react-dom@17.0.1)(react-intersection-observer@8.34.0)(react@17.0.1)",undefined 1`] = `"/$/node_modules/.pnpm/@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0_@fluentui+merge-style_yt7yh6tpppbzu7nx3lzx3f3ife/node_modules/@some/package"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: /react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2) 1`] = `"react-transition-group@4.4.5_react-dom@17.0.2_react@17.0.2"`; -exports[`getDescriptionFileRootFromKey parses: "/@storybook/core@6.5.15(@storybook/builder-webpack5@6.5.15)(@storybook/manager-webpack5@6.5.15)(eslint@8.57.0)(react-dom@17.0.1)(react@17.0.1)(typescript@5.3.3)(webpack@5.88.1)",undefined 1`] = `"/$/node_modules/.pnpm/@storybook+core@6.5.15_@storybook+builder-webpack5@6.5.15_@storybook+manager-webpack5@6.5.15__wnqw6tyxzeiksxiymflshxscdq/node_modules/@storybook/core"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: file:../../../libraries/ts-command-line(@types/node@18.17.15) 1`] = `"file+..+..+..+libraries+ts-command-line_@types+node@18.17.15"`; -exports[`getDescriptionFileRootFromKey parses: "/@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)",undefined 1`] = `"/$/node_modules/.pnpm/@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2/node_modules/@typescript-eslint/utils"`; +exports[`depPathToFilename formats v6 keys (leading /) with pnpm 8 hashing: file:../../../rigs/local-node-rig 1`] = `"file+..+..+..+rigs+local-node-rig"`; -exports[`getDescriptionFileRootFromKey parses: "/autoprefixer@9.8.8",undefined 1`] = `"/$/node_modules/.pnpm/autoprefixer@9.8.8/node_modules/autoprefixer"`; +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 9 hashing (same as v8): @some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0) 1`] = `"@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0"`; -exports[`getDescriptionFileRootFromKey parses: "/autoprefixer@10.4.18(postcss@8.4.36)",undefined 1`] = `"/$/node_modules/.pnpm/autoprefixer@10.4.18_postcss@8.4.36/node_modules/autoprefixer"`; +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 9 hashing (same as v8): @typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2) 1`] = `"@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2"`; -exports[`getDescriptionFileRootFromKey parses: "/react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2)",undefined 1`] = `"/$/node_modules/.pnpm/react-transition-group@4.4.5_react-dom@17.0.2_react@17.0.2/node_modules/react-transition-group"`; +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 9 hashing (same as v8): autoprefixer@9.8.8 1`] = `"autoprefixer@9.8.8"`; -exports[`getDescriptionFileRootFromKey parses: "file:../../../libraries/ts-command-line(@types/node@18.17.15)",@rushstack/ts-command-line 1`] = `"/$/node_modules/.pnpm/file+..+..+..+libraries+ts-command-line_@types+node@18.17.15/node_modules/@rushstack/ts-command-line"`; +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 9 hashing (same as v8): autoprefixer@10.4.18(postcss@8.4.36) 1`] = `"autoprefixer@10.4.18_postcss@8.4.36"`; -exports[`getDescriptionFileRootFromKey parses: "file:../../../rigs/local-node-rig",local-node-rig 1`] = `"/$/node_modules/.pnpm/file+..+..+..+rigs+local-node-rig/node_modules/local-node-rig"`; +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 10 hashing: @fluentui/react-migration-v8-v9@9.9.7(@types/react-dom@17.0.17)(@types/react@17.0.45)(react-dom@17.0.1)(react@17.0.1) 1`] = `"@fluentui+react-migration-v8-v9@9.9.7_@types+react-dom@17.0.17_@types+react@17.0.45_react-dom@17.0.1_react@17.0.1"`; + +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 10 hashing: @some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0) 1`] = `"@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0"`; + +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 10 hashing: @typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2) 1`] = `"@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2"`; + +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 10 hashing: autoprefixer@9.8.8 1`] = `"autoprefixer@9.8.8"`; + +exports[`depPathToFilename formats v9 keys (no leading /) with pnpm 10 hashing: autoprefixer@10.4.18(postcss@8.4.36) 1`] = `"autoprefixer@10.4.18_postcss@8.4.36"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "/@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)(@fluentui/merge-styles@8.6.2)(@fluentui/react@8.117.5)(@fluentui/theme@2.6.45)(@fluentui/utilities@8.15.2)(chart.js@2.9.4)(lodash@4.17.21)(moment@2.29.4)(prop-types@15.8.1)(react-dnd-html5-backend@14.1.0)(react-dnd@14.0.5)(react-dom@17.0.1)(react-intersection-observer@8.34.0)(react@17.0.1)", 1`] = `"/$/node_modules/.pnpm/@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0_@fluentui+merge-style_yt7yh6tpppbzu7nx3lzx3f3ife/node_modules/@some/package"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "/@storybook/core@6.5.15(@storybook/builder-webpack5@6.5.15)(@storybook/manager-webpack5@6.5.15)(eslint@8.57.0)(react-dom@17.0.1)(react@17.0.1)(typescript@5.3.3)(webpack@5.88.1)", 1`] = `"/$/node_modules/.pnpm/@storybook+core@6.5.15_@storybook+builder-webpack5@6.5.15_@storybook+manager-webpack5@6.5.15__wnqw6tyxzeiksxiymflshxscdq/node_modules/@storybook/core"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "/@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)", 1`] = `"/$/node_modules/.pnpm/@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2/node_modules/@typescript-eslint/utils"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "/autoprefixer@9.8.8", 1`] = `"/$/node_modules/.pnpm/autoprefixer@9.8.8/node_modules/autoprefixer"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "/autoprefixer@10.4.18(postcss@8.4.36)", 1`] = `"/$/node_modules/.pnpm/autoprefixer@10.4.18_postcss@8.4.36/node_modules/autoprefixer"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "/react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2)", 1`] = `"/$/node_modules/.pnpm/react-transition-group@4.4.5_react-dom@17.0.2_react@17.0.2/node_modules/react-transition-group"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "file:../../../libraries/ts-command-line(@types/node@18.17.15)",@rushstack/ts-command-line 1`] = `"/$/node_modules/.pnpm/file+..+..+..+libraries+ts-command-line_@types+node@18.17.15/node_modules/@rushstack/ts-command-line"`; + +exports[`getDescriptionFileRootFromKey parses v6 keys (leading /): "file:../../../rigs/local-node-rig",local-node-rig 1`] = `"/$/node_modules/.pnpm/file+..+..+..+rigs+local-node-rig/node_modules/local-node-rig"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)", 1`] = `"/$/node_modules/.pnpm/@some+package@1.2.3_@azure+msal-browser@2.28.1_@azure+msal-common@6.4.0/node_modules/@some/package"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)", 1`] = `"/$/node_modules/.pnpm/@typescript-eslint+utils@6.19.1_eslint@7.7.0_typescript@5.4.2/node_modules/@typescript-eslint/utils"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "autoprefixer@9.8.8", 1`] = `"/$/node_modules/.pnpm/autoprefixer@9.8.8/node_modules/autoprefixer"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "autoprefixer@10.4.18(postcss@8.4.36)", 1`] = `"/$/node_modules/.pnpm/autoprefixer@10.4.18_postcss@8.4.36/node_modules/autoprefixer"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "file:../../../libraries/ts-command-line(@types/node@18.17.15)",@rushstack/ts-command-line 1`] = `"/$/node_modules/.pnpm/file+..+..+..+libraries+ts-command-line_@types+node@18.17.15/node_modules/@rushstack/ts-command-line"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "file:../../../rigs/local-node-rig",local-node-rig 1`] = `"/$/node_modules/.pnpm/file+..+..+..+rigs+local-node-rig/node_modules/local-node-rig"`; + +exports[`getDescriptionFileRootFromKey parses v9 keys (no leading /): "react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2)", 1`] = `"/$/node_modules/.pnpm/react-transition-group@4.4.5_react-dom@17.0.2_react@17.0.2/node_modules/react-transition-group"`; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts b/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts index 458dc1682f2..80c1781bd5c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts @@ -10,7 +10,8 @@ import { computeResolverCacheFromLockfileAsync, type IComputeResolverCacheFromLockfileOptions, type IPartialRushProject, - type IPlatformInfo + type IPlatformInfo, + type PnpmMajorVersion } from '../computeResolverCacheFromLockfileAsync'; import type { IResolverContext } from '../types'; @@ -18,6 +19,7 @@ interface ITestCase { workspaceRoot: string; commonPrefixToTrim: string; lockfileName: string; + pnpmVersion?: PnpmMajorVersion; afterExternalPackagesAsync?: IComputeResolverCacheFromLockfileOptions['afterExternalPackagesAsync']; } @@ -80,6 +82,20 @@ const TEST_CASES: readonly ITestCase[] = [ ]; } } + }, + { + // v9 lockfile with pnpm 9 helpers (v9 key format, MD5 base32 hash, v3 store) + workspaceRoot: '/$root/common/temp/build-tests', + commonPrefixToTrim: '/$root/', + lockfileName: 'build-tests-subspace-v9.yaml', + pnpmVersion: 9 + }, + { + // Same v9 lockfile with pnpm 10 helpers (v9 key format, SHA-256 hash, v10 store) + workspaceRoot: '/$root/common/temp/build-tests', + commonPrefixToTrim: '/$root/', + lockfileName: 'build-tests-subspace-v9.yaml', + pnpmVersion: 10 } ]; @@ -94,7 +110,8 @@ describe(computeResolverCacheFromLockfileAsync.name, () => { }; for (const testCase of TEST_CASES) { - const { workspaceRoot, commonPrefixToTrim, lockfileName, afterExternalPackagesAsync } = testCase; + const { workspaceRoot, commonPrefixToTrim, lockfileName, pnpmVersion, afterExternalPackagesAsync } = + testCase; const lockfile: PnpmShrinkwrapFile | undefined = PnpmShrinkwrapFile.loadFromFile( `${collateralFolder}/${lockfileName}`, @@ -116,17 +133,20 @@ describe(computeResolverCacheFromLockfileAsync.name, () => { }); } + const snapshotName: string = pnpmVersion ? `${lockfileName} (pnpm ${pnpmVersion})` : lockfileName; + const resolverCacheFile = await computeResolverCacheFromLockfileAsync({ workspaceRoot, commonPrefixToTrim, lockfile, platformInfo, projectByImporterPath, + pnpmVersion, afterExternalPackagesAsync }); // Trim undefined properties - expect(JSON.parse(JSON.stringify(resolverCacheFile))).toMatchSnapshot(lockfileName); + expect(JSON.parse(JSON.stringify(resolverCacheFile))).toMatchSnapshot(snapshotName); } }); }); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts b/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts index c26e3eba9b6..dee17978acb 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts @@ -1,7 +1,22 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { createBase32Hash, depPathToFilename, getDescriptionFileRootFromKey } from '../helpers'; +import { createBase32Hash, createShortSha256Hash } from '../pnpm/depPath/hash'; +import { depPathToFilename as depPathToFilenameV8 } from '../pnpm/depPath/v8'; +import { depPathToFilename as depPathToFilenameV9 } from '../pnpm/depPath/v9'; +import { depPathToFilename as depPathToFilenameV10 } from '../pnpm/depPath/v10'; +import { + getDescriptionFileRootFromKey, + extractNameAndVersionFromKey, + resolveDependencyKey +} from '../helpers'; +import { helpers as v8Helpers } from '../pnpm/v8'; +import { helpers as v9Helpers } from '../pnpm/v9'; +import { helpers as v10Helpers } from '../pnpm/v10'; +import { getPnpmVersionHelpersAsync, type IPnpmVersionHelpers } from '../pnpm'; +import { detectPnpmMajorVersion } from '../computeResolverCacheFromLockfileAsync'; +import type { IResolverContext } from '../types'; +import type { PnpmShrinkwrapFile } from '../externals'; describe(createBase32Hash.name, () => { it('hashes', () => { @@ -11,8 +26,16 @@ describe(createBase32Hash.name, () => { }); }); -describe(depPathToFilename.name, () => { - it('formats', () => { +describe(createShortSha256Hash.name, () => { + it('hashes', () => { + for (const input of ['a', 'abracadabra', '(eslint@8.57.0)(typescript@5.4.5)']) { + expect(createShortSha256Hash(input)).toMatchSnapshot(input); + } + }); +}); + +describe('depPathToFilename', () => { + it('formats v6 keys (leading /) with pnpm 8 hashing', () => { for (const input of [ '/autoprefixer@9.8.8', '/autoprefixer@10.4.18(postcss@8.4.36)', @@ -23,13 +46,37 @@ describe(depPathToFilename.name, () => { 'file:../../../rigs/local-node-rig', 'file:../../../libraries/ts-command-line(@types/node@18.17.15)' ]) { - expect(depPathToFilename(input)).toMatchSnapshot(input); + expect(depPathToFilenameV8(input)).toMatchSnapshot(input); + } + }); + + it('formats v9 keys (no leading /) with pnpm 10 hashing', () => { + for (const input of [ + 'autoprefixer@9.8.8', + 'autoprefixer@10.4.18(postcss@8.4.36)', + '@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)', + '@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)', + '@fluentui/react-migration-v8-v9@9.9.7(@types/react-dom@17.0.17)(@types/react@17.0.45)(react-dom@17.0.1)(react@17.0.1)' + ]) { + expect(depPathToFilenameV10(input)).toMatchSnapshot(input); + } + }); + + it('formats v9 keys (no leading /) with pnpm 9 hashing (same as v8)', () => { + for (const input of [ + 'autoprefixer@9.8.8', + 'autoprefixer@10.4.18(postcss@8.4.36)', + '@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)', + '@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)' + ]) { + // pnpm 9 uses identical dep-path hashing and filename logic as pnpm 8 (MD5 base32) + expect(depPathToFilenameV9(input)).toMatchSnapshot(input); } }); }); describe(getDescriptionFileRootFromKey.name, () => { - it('parses', () => { + it('parses v6 keys (leading /)', () => { const lockfileRoot: string = '/$'; for (const { key, name } of [ { key: '/autoprefixer@9.8.8' }, @@ -48,7 +95,321 @@ describe(getDescriptionFileRootFromKey.name, () => { name: '@rushstack/ts-command-line' } ]) { - expect(getDescriptionFileRootFromKey(lockfileRoot, key, name)).toMatchSnapshot(`"${key}",${name}`); + expect(getDescriptionFileRootFromKey(lockfileRoot, key, depPathToFilenameV8, name)).toMatchSnapshot( + `"${key}",${name || ''}` + ); + } + }); + + it('parses v9 keys (no leading /)', () => { + const lockfileRoot: string = '/$'; + for (const { key, name } of [ + { key: 'autoprefixer@9.8.8' }, + { key: 'autoprefixer@10.4.18(postcss@8.4.36)' }, + { key: 'react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2)' }, + { + key: '@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)' + }, + { key: '@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)' }, + { key: 'file:../../../rigs/local-node-rig', name: 'local-node-rig' }, + { + key: 'file:../../../libraries/ts-command-line(@types/node@18.17.15)', + name: '@rushstack/ts-command-line' + } + ]) { + expect(getDescriptionFileRootFromKey(lockfileRoot, key, depPathToFilenameV10, name)).toMatchSnapshot( + `"${key}",${name || ''}` + ); } }); }); + +describe(extractNameAndVersionFromKey.name, () => { + it('extracts name and version from v6 keys (leading /)', () => { + expect(extractNameAndVersionFromKey('/autoprefixer@9.8.8')).toEqual({ + name: 'autoprefixer', + version: '9.8.8' + }); + expect(extractNameAndVersionFromKey('/autoprefixer@10.4.18(postcss@8.4.36)')).toEqual({ + name: 'autoprefixer', + version: '10.4.18' + }); + expect(extractNameAndVersionFromKey('/@some/package@1.2.3(@azure/msal-browser@2.28.1)')).toEqual({ + name: '@some/package', + version: '1.2.3' + }); + expect( + extractNameAndVersionFromKey('/@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)') + ).toEqual({ + name: '@typescript-eslint/utils', + version: '6.19.1' + }); + }); + + it('extracts name and version from v9 keys (no leading /)', () => { + expect(extractNameAndVersionFromKey('autoprefixer@9.8.8')).toEqual({ + name: 'autoprefixer', + version: '9.8.8' + }); + expect(extractNameAndVersionFromKey('autoprefixer@10.4.18(postcss@8.4.36)')).toEqual({ + name: 'autoprefixer', + version: '10.4.18' + }); + expect(extractNameAndVersionFromKey('@some/package@1.2.3(@azure/msal-browser@2.28.1)')).toEqual({ + name: '@some/package', + version: '1.2.3' + }); + expect( + extractNameAndVersionFromKey('@typescript-eslint/utils@6.19.1(eslint@7.7.0)(typescript@5.4.2)') + ).toEqual({ + name: '@typescript-eslint/utils', + version: '6.19.1' + }); + }); + + it('returns undefined for file: keys', () => { + expect(extractNameAndVersionFromKey('file:../../../rigs/local-node-rig')).toBeUndefined(); + expect( + extractNameAndVersionFromKey('file:../../../libraries/ts-command-line(@types/node@18.17.15)') + ).toBeUndefined(); + }); +}); + +describe('buildDependencyKey', () => { + it('pnpm 8 prefixes with /', () => { + expect(v8Helpers.buildDependencyKey('autoprefixer', '9.8.8')).toBe('/autoprefixer@9.8.8'); + expect(v8Helpers.buildDependencyKey('@scope/pkg', '1.0.0')).toBe('/@scope/pkg@1.0.0'); + }); + + it('pnpm 9 does not prefix with /', () => { + expect(v9Helpers.buildDependencyKey('autoprefixer', '9.8.8')).toBe('autoprefixer@9.8.8'); + expect(v9Helpers.buildDependencyKey('@scope/pkg', '1.0.0')).toBe('@scope/pkg@1.0.0'); + }); + + it('pnpm 10 does not prefix with /', () => { + expect(v10Helpers.buildDependencyKey('autoprefixer', '9.8.8')).toBe('autoprefixer@9.8.8'); + expect(v10Helpers.buildDependencyKey('@scope/pkg', '1.0.0')).toBe('@scope/pkg@1.0.0'); + }); +}); + +describe('getStoreIndexPath', () => { + const makeContext: (name: string, version?: string) => IResolverContext = (name, version) => ({ + descriptionFileRoot: '/test', + descriptionFileHash: undefined, + name, + version, + deps: new Map(), + isProject: false, + ordinal: 0 + }); + + it('pnpm 8 uses v3/files/ store layout', () => { + const result: string = v8Helpers.getStoreIndexPath( + '/store', + makeContext('autoprefixer', '9.8.8'), + 'abcdef1234567890' + ); + expect(result).toBe('/store/v3/files/ab/cdef1234567890-index.json'); + }); + + it('pnpm 9 uses v3/files/ store layout (same as v8)', () => { + const result: string = v9Helpers.getStoreIndexPath( + '/store', + makeContext('autoprefixer', '9.8.8'), + 'abcdef1234567890' + ); + expect(result).toBe('/store/v3/files/ab/cdef1234567890-index.json'); + }); + + it('pnpm 10 uses v10/index/ store layout with name@version suffix', () => { + const result: string = v10Helpers.getStoreIndexPath( + '/store', + makeContext('autoprefixer', '9.8.8'), + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' + ); + // hash is truncated to 64 chars, then split at 2 chars + expect(result).toBe( + '/store/v10/index/ab/cdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890-autoprefixer@9.8.8.json' + ); + }); + + it('pnpm 10 replaces / with + in scoped package names', () => { + const result: string = v10Helpers.getStoreIndexPath( + '/store', + makeContext('@scope/pkg', '1.0.0'), + 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899' + ); + expect(result).toBe( + '/store/v10/index/aa/bbccddeeff00112233445566778899aabbccddeeff00112233445566778899-@scope+pkg@1.0.0.json' + ); + }); +}); + +describe(detectPnpmMajorVersion.name, () => { + function makeLockfileStub(majorVersion: number, packageKeys: string[] = []): PnpmShrinkwrapFile { + return { + shrinkwrapFileMajorVersion: majorVersion, + packages: new Map(packageKeys.map((k) => [k, {} as never])) + } as unknown as PnpmShrinkwrapFile; + } + + it('returns configured version when provided', () => { + // Configured version should take precedence over lockfile heuristics + expect(detectPnpmMajorVersion(makeLockfileStub(9), 10)).toBe(10); + expect(detectPnpmMajorVersion(makeLockfileStub(6), 8)).toBe(8); + expect(detectPnpmMajorVersion(makeLockfileStub(9), 9)).toBe(9); + }); + + it('returns 9 for lockfile v9+', () => { + expect(detectPnpmMajorVersion(makeLockfileStub(9))).toBe(9); + expect(detectPnpmMajorVersion(makeLockfileStub(10))).toBe(9); + }); + + it('returns 8 for lockfile v6', () => { + expect(detectPnpmMajorVersion(makeLockfileStub(6))).toBe(8); + expect(detectPnpmMajorVersion(makeLockfileStub(5))).toBe(8); + }); + + it('falls back to key inspection when shrinkwrapFileMajorVersion is 0', () => { + // v6 keys start with / + expect(detectPnpmMajorVersion(makeLockfileStub(0, ['/foo@1.0.0']))).toBe(8); + // v9 keys have no leading / + expect(detectPnpmMajorVersion(makeLockfileStub(0, ['foo@1.0.0']))).toBe(9); + }); + + it('skips file: keys during fallback inspection', () => { + expect(detectPnpmMajorVersion(makeLockfileStub(0, ['file:../local', '/foo@1.0.0']))).toBe(8); + expect(detectPnpmMajorVersion(makeLockfileStub(0, ['file:../local', 'foo@1.0.0']))).toBe(9); + }); + + it('returns 8 when no packages exist', () => { + expect(detectPnpmMajorVersion(makeLockfileStub(0, []))).toBe(8); + }); +}); + +describe(getPnpmVersionHelpersAsync.name, () => { + it('returns helpers for pnpm 8, 9, 10', async () => { + const h8: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(8); + const h9: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(9); + const h10: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(10); + // v8 keys have leading / + expect(h8.buildDependencyKey('foo', '1.0.0')).toBe('/foo@1.0.0'); + // v9/v10 keys have no leading / + expect(h9.buildDependencyKey('foo', '1.0.0')).toBe('foo@1.0.0'); + expect(h10.buildDependencyKey('foo', '1.0.0')).toBe('foo@1.0.0'); + }); + + it('throws for unsupported version', async () => { + await expect(getPnpmVersionHelpersAsync(7 as never)).rejects.toThrow('Unsupported pnpm major version'); + }); +}); + +describe(resolveDependencyKey.name, () => { + const lockfileFolder: string = '/$root'; + + const makeProjectContext: () => IResolverContext = () => ({ + descriptionFileRoot: '/$root/../../../projects/my-app', + descriptionFileHash: undefined, + name: 'my-app', + isProject: true, + deps: new Map(), + ordinal: 0 + }); + + const makePackageContext: () => IResolverContext = () => ({ + descriptionFileRoot: '/$root/node_modules/.pnpm/foo@1.0.0/node_modules/foo', + descriptionFileHash: undefined, + name: 'foo', + isProject: false, + deps: new Map(), + ordinal: 0 + }); + + it('resolves link: specifier for project context', () => { + const result: string = resolveDependencyKey( + lockfileFolder, + 'bar', + 'link:../bar', + makeProjectContext(), + v9Helpers + ); + // path.posix.join resolves the relative segments + expect(result).toBe('/projects/bar'); + }); + + it('resolves link: specifier for non-project context', () => { + const result: string = resolveDependencyKey( + lockfileFolder, + 'bar', + 'link:../bar', + makePackageContext(), + v9Helpers + ); + // path.posix.join resolves the relative path + expect(result).toBe('/bar'); + }); + + it('resolves file: specifier', () => { + const result: string = resolveDependencyKey( + lockfileFolder, + 'my-rig', + 'file:../../../rigs/local-node-rig', + makeProjectContext(), + v9Helpers + ); + expect(result).toContain('node_modules/.pnpm/'); + expect(result).toContain('/node_modules/my-rig'); + }); + + it('resolves specifier found in packageKeys (v6)', () => { + const packageKeys: Set = new Set(['/autoprefixer@9.8.8']); + const result: string = resolveDependencyKey( + lockfileFolder, + 'autoprefixer', + '/autoprefixer@9.8.8', + makeProjectContext(), + v8Helpers, + packageKeys + ); + expect(result).toContain('/node_modules/.pnpm/'); + expect(result).toContain('/node_modules/autoprefixer'); + }); + + it('resolves specifier found in packageKeys (v9)', () => { + const packageKeys: Set = new Set(['autoprefixer@9.8.8']); + const result: string = resolveDependencyKey( + lockfileFolder, + 'autoprefixer', + 'autoprefixer@9.8.8', + makeProjectContext(), + v9Helpers, + packageKeys + ); + expect(result).toContain('/node_modules/.pnpm/'); + expect(result).toContain('/node_modules/autoprefixer'); + }); + + it('builds dependency key for plain version specifiers (v9)', () => { + const result: string = resolveDependencyKey( + lockfileFolder, + 'autoprefixer', + '9.8.8', + makeProjectContext(), + v9Helpers + ); + expect(result).toContain('/node_modules/.pnpm/'); + expect(result).toContain('/node_modules/autoprefixer'); + }); + + it('builds dependency key for plain version specifiers (v8)', () => { + const result: string = resolveDependencyKey( + lockfileFolder, + 'autoprefixer', + '9.8.8', + makeProjectContext(), + v8Helpers + ); + expect(result).toContain('/node_modules/.pnpm/'); + expect(result).toContain('/node_modules/autoprefixer'); + }); +}); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/types.ts b/rush-plugins/rush-resolver-cache-plugin/src/types.ts index c58938042d4..f32953fd67b 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/types.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/types.ts @@ -5,6 +5,7 @@ export interface IResolverContext { descriptionFileRoot: string; descriptionFileHash: string | undefined; name: string; + version?: string; deps: Map; isProject: boolean; ordinal: number; diff --git a/rush-plugins/rush-resolver-cache-plugin/test-collateral/build-tests-subspace-v9.yaml b/rush-plugins/rush-resolver-cache-plugin/test-collateral/build-tests-subspace-v9.yaml new file mode 100644 index 00000000000..9ee65a74ffa --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/test-collateral/build-tests-subspace-v9.yaml @@ -0,0 +1,161 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: {} + + ../../../build-tests-subspace/rush-lib-test: + dependencies: + autoprefixer: + specifier: ^9.8.8 + version: 9.8.8 + '@babel/code-frame': + specifier: ^7.24.2 + version: 7.24.2 + devDependencies: + typescript: + specifier: ~5.4.2 + version: 5.4.5 + + ../../../build-tests-subspace/rush-sdk-test: + dependencies: + '@scope/bar': + specifier: ^2.0.0 + version: 2.0.0(typescript@5.4.5) + '@some/package': + specifier: ^1.2.3 + version: 1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)(@fluentui/merge-styles@8.6.2)(@fluentui/react@8.117.5)(react-dom@17.0.1)(react@17.0.1) + devDependencies: + typescript: + specifier: ~5.4.2 + version: 5.4.5 + +packages: + autoprefixer@9.8.8: + resolution: + { + integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + } + + browserslist@4.22.1: + resolution: + { + integrity: sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ== + } + + '@babel/code-frame@7.24.2': + resolution: + { + integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== + } + engines: { node: '>=6.9.0' } + + '@babel/highlight@7.24.5': + resolution: + { + integrity: sha512-8lLMua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw== + } + engines: { node: '>=6.9.0' } + + picocolors@1.0.1: + resolution: + { + integrity: sha512-YLPHc8yASwT2u8HKHzcmMaYaR2gKQLmFLErA4gTMaJxRrCK6FPHQhpmCaGQBHRUqoYoHqs2a7MFxGpEbRHLzQ== + } + + typescript@5.4.5: + resolution: + { + integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== + } + engines: { node: '>=14.17' } + hasBin: true + + '@scope/bar@2.0.0': + resolution: { integrity: sha512-fakeintegrityhash1234567890abcdefghijklmnopqrstuvwxyz== } + peerDependencies: + typescript: '>=5.0' + + '@some/package@1.2.3': + resolution: { integrity: sha512-somefakeintegrityhash1234567890abcdefghijklmnopqrstu== } + peerDependencies: + '@azure/msal-browser': '>=2.0.0' + '@azure/msal-common': '>=6.0.0' + '@fluentui/merge-styles': '>=8.0.0' + '@fluentui/react': '>=8.0.0' + react-dom: '>=17.0.0' + react: '>=17.0.0' + + '@azure/msal-browser@2.28.1': + resolution: { integrity: sha512-fakemsalbrowserintegrityhash1234567890abcdefghijklmno== } + + '@azure/msal-common@6.4.0': + resolution: { integrity: sha512-fakemsalcommonintegrityhash1234567890abcdefghijklmnop== } + + '@fluentui/merge-styles@8.6.2': + resolution: { integrity: sha512-fakemergestylesintegrityhash1234567890abcdefghijklmno== } + + '@fluentui/react@8.117.5': + resolution: { integrity: sha512-fakefluentuireactintegrityhash1234567890abcdefghijklm== } + peerDependencies: + react-dom: '>=17.0.0' + react: '>=17.0.0' + + react-dom@17.0.1: + resolution: { integrity: sha512-fakereactdomintegrityhash1234567890abcdefghijklmnopqr== } + peerDependencies: + react: 17.0.1 + + react@17.0.1: + resolution: { integrity: sha512-fakereactintegrityhash1234567890abcdefghijklmnopqrstu== } + +snapshots: + autoprefixer@9.8.8: + dependencies: + browserslist: 4.22.1 + + browserslist@4.22.1: {} + + '@babel/code-frame@7.24.2': + dependencies: + '@babel/highlight': 7.24.5 + picocolors: 1.0.1 + + '@babel/highlight@7.24.5': {} + + picocolors@1.0.1: {} + + typescript@5.4.5: {} + + '@scope/bar@2.0.0(typescript@5.4.5)': + dependencies: + typescript: 5.4.5 + + '@some/package@1.2.3(@azure/msal-browser@2.28.1)(@azure/msal-common@6.4.0)(@fluentui/merge-styles@8.6.2)(@fluentui/react@8.117.5)(react-dom@17.0.1)(react@17.0.1)': + dependencies: + '@azure/msal-browser': 2.28.1 + '@azure/msal-common': 6.4.0 + '@fluentui/merge-styles': 8.6.2 + '@fluentui/react': 8.117.5(react-dom@17.0.1)(react@17.0.1) + react-dom: 17.0.1(react@17.0.1) + react: 17.0.1 + + '@azure/msal-browser@2.28.1': {} + + '@azure/msal-common@6.4.0': {} + + '@fluentui/merge-styles@8.6.2': {} + + '@fluentui/react@8.117.5(react-dom@17.0.1)(react@17.0.1)': + dependencies: + react-dom: 17.0.1(react@17.0.1) + react: 17.0.1 + + react-dom@17.0.1(react@17.0.1): + dependencies: + react: 17.0.1 + + react@17.0.1: {} From 8fb09406f94834d2a364da485653638dfee2a24d Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:36:00 +0000 Subject: [PATCH 2/3] 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> --- .../computeResolverCacheFromLockfileAsync.ts | 44 +------------------ ...esolverCacheFromLockfileAsync.test.ts.snap | 6 +-- ...puteResolverCacheFromLockfileAsync.test.ts | 11 +++-- .../src/test/helpers.test.ts | 44 ------------------- 4 files changed, 12 insertions(+), 93 deletions(-) diff --git a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts index 7e19e7dee9c..8c7b7398b11 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts @@ -141,10 +141,8 @@ export interface IComputeResolverCacheFromLockfileOptions { /** * The major version of pnpm configured in rush.json (e.g. `"10.27.0"` → 10). * Used to select the correct dep-path hashing algorithm and store layout. - * When omitted, the version is inferred from the lockfile format (v6 → pnpm 8, - * v9 → pnpm 9). */ - pnpmVersion?: PnpmMajorVersion; + pnpmVersion: PnpmMajorVersion; /** * A callback to process external packages after they have been enumerated. * Broken out as a separate function to facilitate testing without hitting the disk. @@ -168,44 +166,6 @@ function convertToSlashes(path: string): string { return path.replace(/\\/g, '/'); } -/** - * Detects the pnpm major version from the lockfile format and an optional - * caller-supplied version (derived from rush.json `pnpmVersion`). - * - * @param lockfile - The parsed shrinkwrap / lockfile - * @param configuredPnpmVersion - The pnpm major version from rush.json, if available. - * When provided this takes precedence, because the lockfile alone cannot distinguish - * pnpm 9 from pnpm 10 (both use lockfile v9). - */ -export function detectPnpmMajorVersion( - lockfile: PnpmShrinkwrapFile, - configuredPnpmVersion?: PnpmMajorVersion -): PnpmMajorVersion { - if (configuredPnpmVersion !== undefined) { - return configuredPnpmVersion; - } - - // Detect from lockfile version - if (lockfile.shrinkwrapFileMajorVersion >= 9) { - // Lockfile v9 is shared by pnpm 9 and pnpm 10. - // Without the configured version we cannot tell them apart; default to 9 - // (v8 dep-path algorithm, v3 store, v9 key format). - return 9; - } - - if (lockfile.shrinkwrapFileMajorVersion > 0) { - return 8; - } - - // Fallback for lockfiles where version parsing failed: inspect the first non-file package key. - for (const key of lockfile.packages.keys()) { - if (!key.startsWith('file:')) { - return key.startsWith('/') ? 8 : 9; - } - } - return 8; -} - /** * Given a lockfile and information about the workspace and platform, computes the resolver cache file. * @param params - The options for computing the resolver cache @@ -223,7 +183,7 @@ export async function computeResolverCacheFromLockfileAsync( const contexts: Map = new Map(); const missingOptionalDependencies: Set = new Set(); - const pnpmVersion: PnpmMajorVersion = detectPnpmMajorVersion(lockfile, params.pnpmVersion); + const pnpmVersion: PnpmMajorVersion = params.pnpmVersion; const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmVersion); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap b/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap index 0e029510138..62a1568b26b 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/__snapshots__/computeResolverCacheFromLockfileAsync.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: build-tests-subspace.yaml 1`] = ` +exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: build-tests-subspace.yaml (pnpm 8) 1`] = ` Object { "basePath": "/$root/", "contexts": Array [ @@ -6606,7 +6606,7 @@ Object { } `; -exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: bundled-dependencies.yaml 1`] = ` +exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: bundled-dependencies.yaml (pnpm 8) 1`] = ` Object { "basePath": "/$root/", "contexts": Array [ @@ -6780,7 +6780,7 @@ Object { } `; -exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: default-subspace.yaml 1`] = ` +exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: default-subspace.yaml (pnpm 8) 1`] = ` Object { "basePath": "/$root/", "contexts": Array [ diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts b/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts index 80c1781bd5c..51c22183bf9 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/computeResolverCacheFromLockfileAsync.test.ts @@ -19,7 +19,7 @@ interface ITestCase { workspaceRoot: string; commonPrefixToTrim: string; lockfileName: string; - pnpmVersion?: PnpmMajorVersion; + pnpmVersion: PnpmMajorVersion; afterExternalPackagesAsync?: IComputeResolverCacheFromLockfileOptions['afterExternalPackagesAsync']; } @@ -28,18 +28,21 @@ const TEST_CASES: readonly ITestCase[] = [ // Validate with POSIX-style path inputs workspaceRoot: '/$root/common/temp/build-tests', commonPrefixToTrim: '/$root/', - lockfileName: 'build-tests-subspace.yaml' + lockfileName: 'build-tests-subspace.yaml', + pnpmVersion: 8 }, { // Validate that it works with Windows-style path inputs workspaceRoot: '\\$root\\common\\temp\\default', commonPrefixToTrim: '\\$root\\', - lockfileName: 'default-subspace.yaml' + lockfileName: 'default-subspace.yaml', + pnpmVersion: 8 }, { workspaceRoot: '/$root/common/temp/bundled-dependencies', commonPrefixToTrim: '/$root/', lockfileName: 'bundled-dependencies.yaml', + pnpmVersion: 8, afterExternalPackagesAsync: async (contexts: Map) => { for (const context of contexts.values()) { context.nestedPackageDirs = [ @@ -133,7 +136,7 @@ describe(computeResolverCacheFromLockfileAsync.name, () => { }); } - const snapshotName: string = pnpmVersion ? `${lockfileName} (pnpm ${pnpmVersion})` : lockfileName; + const snapshotName: string = `${lockfileName} (pnpm ${pnpmVersion})`; const resolverCacheFile = await computeResolverCacheFromLockfileAsync({ workspaceRoot, diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts b/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts index dee17978acb..7da36dc2ad6 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts @@ -14,9 +14,7 @@ import { helpers as v8Helpers } from '../pnpm/v8'; import { helpers as v9Helpers } from '../pnpm/v9'; import { helpers as v10Helpers } from '../pnpm/v10'; import { getPnpmVersionHelpersAsync, type IPnpmVersionHelpers } from '../pnpm'; -import { detectPnpmMajorVersion } from '../computeResolverCacheFromLockfileAsync'; import type { IResolverContext } from '../types'; -import type { PnpmShrinkwrapFile } from '../externals'; describe(createBase32Hash.name, () => { it('hashes', () => { @@ -245,48 +243,6 @@ describe('getStoreIndexPath', () => { }); }); -describe(detectPnpmMajorVersion.name, () => { - function makeLockfileStub(majorVersion: number, packageKeys: string[] = []): PnpmShrinkwrapFile { - return { - shrinkwrapFileMajorVersion: majorVersion, - packages: new Map(packageKeys.map((k) => [k, {} as never])) - } as unknown as PnpmShrinkwrapFile; - } - - it('returns configured version when provided', () => { - // Configured version should take precedence over lockfile heuristics - expect(detectPnpmMajorVersion(makeLockfileStub(9), 10)).toBe(10); - expect(detectPnpmMajorVersion(makeLockfileStub(6), 8)).toBe(8); - expect(detectPnpmMajorVersion(makeLockfileStub(9), 9)).toBe(9); - }); - - it('returns 9 for lockfile v9+', () => { - expect(detectPnpmMajorVersion(makeLockfileStub(9))).toBe(9); - expect(detectPnpmMajorVersion(makeLockfileStub(10))).toBe(9); - }); - - it('returns 8 for lockfile v6', () => { - expect(detectPnpmMajorVersion(makeLockfileStub(6))).toBe(8); - expect(detectPnpmMajorVersion(makeLockfileStub(5))).toBe(8); - }); - - it('falls back to key inspection when shrinkwrapFileMajorVersion is 0', () => { - // v6 keys start with / - expect(detectPnpmMajorVersion(makeLockfileStub(0, ['/foo@1.0.0']))).toBe(8); - // v9 keys have no leading / - expect(detectPnpmMajorVersion(makeLockfileStub(0, ['foo@1.0.0']))).toBe(9); - }); - - it('skips file: keys during fallback inspection', () => { - expect(detectPnpmMajorVersion(makeLockfileStub(0, ['file:../local', '/foo@1.0.0']))).toBe(8); - expect(detectPnpmMajorVersion(makeLockfileStub(0, ['file:../local', 'foo@1.0.0']))).toBe(9); - }); - - it('returns 8 when no packages exist', () => { - expect(detectPnpmMajorVersion(makeLockfileStub(0, []))).toBe(8); - }); -}); - describe(getPnpmVersionHelpersAsync.name, () => { it('returns helpers for pnpm 8, 9, 10', async () => { const h8: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(8); From 190358ba9c1b87bd3f811974d04601ed4dc53cf5 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:41:28 +0000 Subject: [PATCH 3/3] 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> --- .../src/afterInstallAsync.ts | 21 +++++++++--- .../computeResolverCacheFromLockfileAsync.ts | 32 +++++++++++-------- .../rush-resolver-cache-plugin/src/helpers.ts | 20 ++++++------ .../pnpm/{index.ts => pnpmVersionHelpers.ts} | 0 .../src/pnpm/store/v10.ts | 12 +++---- .../src/pnpm/v10.ts | 2 +- .../rush-resolver-cache-plugin/src/pnpm/v8.ts | 2 +- .../rush-resolver-cache-plugin/src/pnpm/v9.ts | 2 +- .../src/test/helpers.test.ts | 2 +- 9 files changed, 53 insertions(+), 40 deletions(-) rename rush-plugins/rush-resolver-cache-plugin/src/pnpm/{index.ts => pnpmVersionHelpers.ts} (100%) diff --git a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts index bbacb70ab4c..301633b0497 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts @@ -16,7 +16,11 @@ import { computeResolverCacheFromLockfileAsync, type IPlatformInfo } from './computeResolverCacheFromLockfileAsync'; -import { type PnpmMajorVersion, type IPnpmVersionHelpers, getPnpmVersionHelpersAsync } from './pnpm'; +import { + type PnpmMajorVersion, + type IPnpmVersionHelpers, + getPnpmVersionHelpersAsync +} from './pnpm/pnpmVersionHelpers'; import type { IResolverContext } from './types'; /** @@ -84,10 +88,17 @@ export async function afterInstallAsync( const pnpmMajorVersion: PnpmMajorVersion = (() => { const major: number = parseInt(rushConfiguration.packageManagerToolVersion, 10); - if (major >= 10) return 10; - if (major >= 9) return 9; - return 8; - })() as PnpmMajorVersion; + switch (major) { + case 10: + return 10; + case 9: + return 9; + case 8: + return 8; + default: + throw new Error(`Unsupported pnpm major version: ${major}`); + } + })(); const pnpmHelpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmMajorVersion); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts index 8c7b7398b11..054cc6cb78c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts @@ -15,7 +15,11 @@ import { createContextSerializer, extractNameAndVersionFromKey } from './helpers'; -import { type PnpmMajorVersion, type IPnpmVersionHelpers, getPnpmVersionHelpersAsync } from './pnpm'; +import { + type PnpmMajorVersion, + type IPnpmVersionHelpers, + getPnpmVersionHelpersAsync +} from './pnpm/pnpmVersionHelpers'; import type { IResolverContext } from './types'; /** @@ -112,7 +116,7 @@ function extractBundledDependencies( } // Re-export for downstream consumers -export type { PnpmMajorVersion, IPnpmVersionHelpers } from './pnpm'; +export type { PnpmMajorVersion, IPnpmVersionHelpers } from './pnpm/pnpmVersionHelpers'; /** * Options for computing the resolver cache from a lockfile. @@ -157,13 +161,15 @@ export interface IComputeResolverCacheFromLockfileOptions { ) => Promise; } +const BACKSLASH_REGEX: RegExp = /\\/g; + /** * Copied from `@rushstack/node-core-library/src/Path.ts` to avoid expensive dependency * @param path - Path using backslashes as path separators * @returns The same string using forward slashes as path separators */ function convertToSlashes(path: string): string { - return path.replace(/\\/g, '/'); + return path.replace(BACKSLASH_REGEX, '/'); } /** @@ -183,12 +189,12 @@ export async function computeResolverCacheFromLockfileAsync( const contexts: Map = new Map(); const missingOptionalDependencies: Set = new Set(); - const pnpmVersion: PnpmMajorVersion = params.pnpmVersion; + const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(params.pnpmVersion); - const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(pnpmVersion); + const { packages } = lockfile; // Enumerate external dependencies first, to simplify looping over them for store data - for (const [key, pack] of lockfile.packages) { + for (const [key, pack] of packages) { let name: string | undefined = pack.name; const descriptionFileRoot: string = getDescriptionFileRootFromKey( workspaceRoot, @@ -208,9 +214,7 @@ export async function computeResolverCacheFromLockfileAsync( // Extract name and version from the key if not already provided const parsed: { name: string; version: string } | undefined = extractNameAndVersionFromKey(key); if (parsed) { - if (!name) { - name = parsed.name; - } + name ||= parsed.name; } if (!name) { @@ -231,10 +235,10 @@ export async function computeResolverCacheFromLockfileAsync( contexts.set(descriptionFileRoot, context); if (pack.dependencies) { - resolveDependencies(workspaceRoot, pack.dependencies, context, helpers, lockfile.packages); + resolveDependencies(workspaceRoot, pack.dependencies, context, helpers, packages); } if (pack.optionalDependencies) { - resolveDependencies(workspaceRoot, pack.optionalDependencies, context, helpers, lockfile.packages); + resolveDependencies(workspaceRoot, pack.optionalDependencies, context, helpers, packages); } } @@ -275,13 +279,13 @@ export async function computeResolverCacheFromLockfileAsync( contexts.set(descriptionFileRoot, context); if (importer.dependencies) { - resolveDependencies(workspaceRoot, importer.dependencies, context, helpers, lockfile.packages); + resolveDependencies(workspaceRoot, importer.dependencies, context, helpers, packages); } if (importer.devDependencies) { - resolveDependencies(workspaceRoot, importer.devDependencies, context, helpers, lockfile.packages); + resolveDependencies(workspaceRoot, importer.devDependencies, context, helpers, packages); } if (importer.optionalDependencies) { - resolveDependencies(workspaceRoot, importer.optionalDependencies, context, helpers, lockfile.packages); + resolveDependencies(workspaceRoot, importer.optionalDependencies, context, helpers, packages); } } diff --git a/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts b/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts index cacc5449ed5..8f1727d5482 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/helpers.ts @@ -6,7 +6,7 @@ import * as path from 'node:path'; import type { ISerializedResolveContext } from '@rushstack/webpack-workspace-resolve-plugin'; import type { IDependencyEntry, IResolverContext } from './types'; -import type { IPnpmVersionHelpers } from './pnpm'; +import type { IPnpmVersionHelpers } from './pnpm/pnpmVersionHelpers'; /** * Computes the root folder for a dependency from a reference to it in another package @@ -26,19 +26,17 @@ export function resolveDependencyKey( packageKeys?: { has(key: string): boolean } ): string { if (specifier.startsWith('link:')) { - if (context.isProject) { - return path.posix.join(context.descriptionFileRoot, specifier.slice(5)); - } else { - return path.posix.join(lockfileFolder, specifier.slice(5)); - } + return path.posix.join( + context.isProject ? context.descriptionFileRoot : lockfileFolder, + specifier.slice(5) + ); } else if (specifier.startsWith('file:')) { return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename, key); - } else if (packageKeys?.has(specifier)) { - // The specifier is a full package key (v6: '/pkg@ver', v9: 'pkg@ver') - return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename); } else { - const fullKey: string = helpers.buildDependencyKey(key, specifier); - return getDescriptionFileRootFromKey(lockfileFolder, fullKey, helpers.depPathToFilename); + const resolvedKey: string = packageKeys?.has(specifier) + ? specifier + : helpers.buildDependencyKey(key, specifier); + return getDescriptionFileRootFromKey(lockfileFolder, resolvedKey, helpers.depPathToFilename); } } diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/index.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/pnpmVersionHelpers.ts similarity index 100% rename from rush-plugins/rush-resolver-cache-plugin/src/pnpm/index.ts rename to rush-plugins/rush-resolver-cache-plugin/src/pnpm/pnpmVersionHelpers.ts diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts index 959907fac75..0a518e1d4e2 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts @@ -5,17 +5,19 @@ // {storeDir}/v10/index/{hash[0:2]}/{hash[2:64]}-{name}@{version}.json // Falls back to directory scan when the primary path doesn't exist. -import { existsSync, readdirSync } from 'node:fs'; +import { type Dirent, existsSync, readdirSync } from 'node:fs'; import type { IResolverContext } from '../../types'; +const SCOPE_SEPARATOR_REGEX: RegExp = /\//g; + export function getStoreIndexPath(pnpmStorePath: string, context: IResolverContext, hash: string): string { // pnpm 10 truncates integrity hashes to 32 bytes (64 hex chars) for index paths. const truncHash: string = hash.length > 64 ? hash.slice(0, 64) : hash; const hashDir: string = truncHash.slice(0, 2); const hashRest: string = truncHash.slice(2); // pnpm 10 index path format: /-@.json - const pkgName: string = (context.name || '').replace(/\//g, '+'); + const pkgName: string = (context.name || '').replace(SCOPE_SEPARATOR_REGEX, '+'); const nameVer: string = context.version ? `${pkgName}@${context.version}` : pkgName; let indexPath: string = `${pnpmStorePath}/v10/index/${hashDir}/${hashRest}-${nameVer}.json`; // For truncated/hashed folder names, nameVer from the key may be wrong. @@ -24,10 +26,8 @@ export function getStoreIndexPath(pnpmStorePath: string, context: IResolverConte const dir: string = `${pnpmStorePath}/v10/index/${hashDir}/`; const filePrefix: string = `${hashRest}-`; try { - const entries: import('node:fs').Dirent[] = readdirSync(dir, { withFileTypes: true }); - const match: import('node:fs').Dirent | undefined = entries.find( - (e) => e.isFile() && e.name.startsWith(filePrefix) - ); + const entries: Dirent[] = readdirSync(dir, { withFileTypes: true }); + const match: Dirent | undefined = entries.find((e) => e.isFile() && e.name.startsWith(filePrefix)); if (match) { indexPath = dir + match.name; } diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts index 3806a5477c3..571622dec80 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v10.ts @@ -3,7 +3,7 @@ // pnpm 10: lockfile v9 (keys have no leading '/'), store v10, SHA-256 hex hash -import type { IPnpmVersionHelpers } from '.'; +import type { IPnpmVersionHelpers } from './pnpmVersionHelpers'; import { depPathToFilename } from './depPath/v10'; import { buildDependencyKey } from './keys/v9'; import { getStoreIndexPath } from './store/v10'; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts index a57f5a0317c..055abcf3884 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v8.ts @@ -3,7 +3,7 @@ // pnpm 8: lockfile v6 (keys start with '/'), store v3, MD5 base32 hash -import type { IPnpmVersionHelpers } from '.'; +import type { IPnpmVersionHelpers } from './pnpmVersionHelpers'; import { depPathToFilename } from './depPath/v8'; import { buildDependencyKey } from './keys/v6'; import { getStoreIndexPath } from './store/v3'; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts index 7cb2845b017..8bfedcd6514 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/v9.ts @@ -3,7 +3,7 @@ // pnpm 9: lockfile v9 (keys have no leading '/'), store v3, MD5 base32 hash -import type { IPnpmVersionHelpers } from '.'; +import type { IPnpmVersionHelpers } from './pnpmVersionHelpers'; // pnpm 9 uses the same dep-path hashing algorithm as pnpm 8 (MD5 base32) // but a different depPathToFilenameUnescaped (indexOf('@') vs lastIndexOf('/')) import { depPathToFilename } from './depPath/v9'; diff --git a/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts b/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts index 7da36dc2ad6..c9c5b51c12f 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/test/helpers.test.ts @@ -13,7 +13,7 @@ import { import { helpers as v8Helpers } from '../pnpm/v8'; import { helpers as v9Helpers } from '../pnpm/v9'; import { helpers as v10Helpers } from '../pnpm/v10'; -import { getPnpmVersionHelpersAsync, type IPnpmVersionHelpers } from '../pnpm'; +import { getPnpmVersionHelpersAsync, type IPnpmVersionHelpers } from '../pnpm/pnpmVersionHelpers'; import type { IResolverContext } from '../types'; describe(createBase32Hash.name, () => {