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..301633b0497 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts @@ -16,6 +16,11 @@ import { computeResolverCacheFromLockfileAsync, type IPlatformInfo } from './computeResolverCacheFromLockfileAsync'; +import { + type PnpmMajorVersion, + type IPnpmVersionHelpers, + getPnpmVersionHelpersAsync +} from './pnpm/pnpmVersionHelpers'; import type { IResolverContext } from './types'; /** @@ -79,10 +84,26 @@ 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); + 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); 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 +187,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 +272,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..054cc6cb78c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/computeResolverCacheFromLockfileAsync.ts @@ -9,7 +9,17 @@ 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/pnpmVersionHelpers'; import type { IResolverContext } from './types'; /** @@ -105,6 +115,9 @@ function extractBundledDependencies( } } +// Re-export for downstream consumers +export type { PnpmMajorVersion, IPnpmVersionHelpers } from './pnpm/pnpmVersionHelpers'; + /** * Options for computing the resolver cache from a lockfile. */ @@ -129,6 +142,11 @@ 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. + */ + 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. @@ -143,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, '/'); } /** @@ -169,10 +189,19 @@ export async function computeResolverCacheFromLockfileAsync( const contexts: Map = new Map(); const missingOptionalDependencies: Set = new Set(); + const helpers: IPnpmVersionHelpers = await getPnpmVersionHelpersAsync(params.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, 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 +211,10 @@ 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) { + name ||= parsed.name; } if (!name) { @@ -196,6 +226,7 @@ export async function computeResolverCacheFromLockfileAsync( descriptionFileHash: integrity, isProject: false, name, + version: parsed?.version, deps: new Map(), ordinal: -1, optional: pack.optional @@ -204,10 +235,10 @@ export async function computeResolverCacheFromLockfileAsync( contexts.set(descriptionFileRoot, context); if (pack.dependencies) { - resolveDependencies(workspaceRoot, pack.dependencies, context); + resolveDependencies(workspaceRoot, pack.dependencies, context, helpers, packages); } if (pack.optionalDependencies) { - resolveDependencies(workspaceRoot, pack.optionalDependencies, context); + resolveDependencies(workspaceRoot, pack.optionalDependencies, context, helpers, packages); } } @@ -248,13 +279,13 @@ export async function computeResolverCacheFromLockfileAsync( contexts.set(descriptionFileRoot, context); if (importer.dependencies) { - resolveDependencies(workspaceRoot, importer.dependencies, context); + resolveDependencies(workspaceRoot, importer.dependencies, context, helpers, packages); } if (importer.devDependencies) { - resolveDependencies(workspaceRoot, importer.devDependencies, context); + resolveDependencies(workspaceRoot, importer.devDependencies, context, helpers, packages); } if (importer.optionalDependencies) { - resolveDependencies(workspaceRoot, importer.optionalDependencies, context); + 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 99ade3da188..8f1727d5482 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/pnpmVersionHelpers'; /** * Computes the root folder for a dependency from a reference to it in another package @@ -60,26 +14,29 @@ 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 (context.isProject) { - return path.posix.join(context.descriptionFileRoot, specifier.slice(5)); - } else { - return path.posix.join(lockfileFolder, specifier.slice(5)); - } + if (specifier.startsWith('link:')) { + return path.posix.join( + context.isProject ? context.descriptionFileRoot : lockfileFolder, + specifier.slice(5) + ); } else if (specifier.startsWith('file:')) { - return getDescriptionFileRootFromKey(lockfileFolder, specifier, key); + return getDescriptionFileRootFromKey(lockfileFolder, specifier, helpers.depPathToFilename, key); } else { - return getDescriptionFileRootFromKey(lockfileFolder, `/${key}@${specifier}`); + const resolvedKey: string = packageKeys?.has(specifier) + ? specifier + : helpers.buildDependencyKey(key, specifier); + return getDescriptionFileRootFromKey(lockfileFolder, resolvedKey, helpers.depPathToFilename); } } @@ -87,12 +44,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 +70,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/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/pnpmVersionHelpers.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/pnpmVersionHelpers.ts new file mode 100644 index 00000000000..5d5e14d812c --- /dev/null +++ b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/pnpmVersionHelpers.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/store/v10.ts b/rush-plugins/rush-resolver-cache-plugin/src/pnpm/store/v10.ts new file mode 100644 index 00000000000..0a518e1d4e2 --- /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 { 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(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. + // 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: Dirent[] = readdirSync(dir, { withFileTypes: true }); + const match: 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..571622dec80 --- /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 './pnpmVersionHelpers'; +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..055abcf3884 --- /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 './pnpmVersionHelpers'; +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..8bfedcd6514 --- /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 './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'; +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..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 [ @@ -6392,7 +6392,221 @@ Object { } `; -exports[`computeResolverCacheFromLockfileAsync matches snapshot behavior: bundled-dependencies.yaml 1`] = ` +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 (pnpm 8) 1`] = ` Object { "basePath": "/$root/", "contexts": Array [ @@ -6566,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/__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..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 @@ -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']; } @@ -26,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 = [ @@ -80,6 +85,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 +113,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 +136,20 @@ describe(computeResolverCacheFromLockfileAsync.name, () => { }); } + const snapshotName: string = `${lockfileName} (pnpm ${pnpmVersion})`; + 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..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 @@ -1,7 +1,20 @@ // 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/pnpmVersionHelpers'; +import type { IResolverContext } from '../types'; describe(createBase32Hash.name, () => { it('hashes', () => { @@ -11,8 +24,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 +44,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 +93,279 @@ 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(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: {}