Skip to content

rush-resolver-cache-plugin: add pnpm 10 / lockfile v9 compatibility#5749

Open
bmiddha wants to merge 1 commit intomainfrom
bmiddha/resolver-cache-pnpm-10
Open

rush-resolver-cache-plugin: add pnpm 10 / lockfile v9 compatibility#5749
bmiddha wants to merge 1 commit intomainfrom
bmiddha/resolver-cache-pnpm-10

Conversation

@bmiddha
Copy link
Copy Markdown
Member

@bmiddha bmiddha commented Apr 7, 2026

Summary

add pnpm 10 / lockfile v9 compatibility to rush-resolver-cache-plugin

Details

rush-resolver-cache-plugin does not support v9 pnpm lockfile format
support pnpm lockfile format v9 and update tests for v6 and v9 format.

How it was tested

unit tests and manually tested integration with pnpm 10 repo.

Impacted documentation

@bmiddha bmiddha force-pushed the bmiddha/resolver-cache-pnpm-10 branch from 6034479 to 3ff472b Compare April 7, 2026 17:49

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)",undefined 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"`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the undefined coming from here?

name: '@rushstack/ts-command-line'
}
]) {
expect(getDescriptionFileRootFromKey(lockfileRoot, key, name)).toMatchSnapshot(`"${key}",${name}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name is only defined for one of these; merge key and name for the snapshot name? Or default to '' for the name?

Comment on lines +182 to +216
// 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);
// Build the bare name@version using context.name and version from the .pnpm folder path.
// The .pnpm folder name format is: <name+ver>_<peer1>_<peer2>/node_modules/<name>
const pkgName: string = (context.name || '').replace(/\//g, '+');
const pnpmSegment: string | undefined = context.descriptionFileRoot.split('/node_modules/.pnpm/')[1];
const folderName: string = pnpmSegment ? pnpmSegment.split('/node_modules/')[0] : '';
const namePrefix: string = `${pkgName}@`;
const nameStart: number = folderName.indexOf(namePrefix);
let nameVer: string = folderName;
if (nameStart !== -1) {
const afterName: string = folderName.slice(nameStart + namePrefix.length);
// Version ends at first _ followed by a letter/@ (peer dep separator)
const peerSep: number = afterName.search(/_[a-zA-Z@]/);
const version: string = peerSep !== -1 ? afterName.slice(0, peerSep) : afterName;
nameVer = `${pkgName}@${version}`;
}
indexPath = `${pnpmStoreDir}${hashDir}/${hashRest}-${nameVer}.json`;
// For truncated/hashed folder names, nameVer from the folder path may be wrong.
// Fallback: scan the directory for a file matching the hash prefix.
if (!existsSync(indexPath)) {
const dir: string = `${pnpmStoreDir}${hashDir}/`;
const filePrefix: string = `${hashRest}-`;
try {
const files: string[] = readdirSync(dir);
const match: string | undefined = files.find((f) => f.startsWith(filePrefix));
if (match) {
indexPath = dir + match;
}
} catch {
// ignore
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this whole thing in a helper function.

Comment on lines +189 to +190
const pnpmSegment: string | undefined = context.descriptionFileRoot.split('/node_modules/.pnpm/')[1];
const folderName: string = pnpmSegment ? pnpmSegment.split('/node_modules/')[0] : '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to avoid .split in this code; this is a hot path.

const hashRest: string = truncHash.slice(2);
// Build the bare name@version using context.name and version from the .pnpm folder path.
// The .pnpm folder name format is: <name+ver>_<peer1>_<peer2>/node_modules/<name>
const pkgName: string = (context.name || '').replace(/\//g, '+');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just plumb the version that we extract from the key in the lockfile into the context object instead of recomputing it.

Comment on lines +177 to +179
: !Array.from(lockfile.packages.keys())
.find((k) => !k.startsWith('file:'))
?.startsWith('/');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a helper function that early returns instead of cloning the entire key space.

// Handle v9 lockfile keys: @scope/name@version or name@version
const searchStart: number = key.startsWith('@') ? key.indexOf('/') + 1 : 0;
const versionIndex: number = key.indexOf('@', searchStart);
if (versionIndex !== -1) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put the versions on context so we don't have to compute them later.

if (filename.length > 120 || (filename !== filename.toLowerCase() && !filename.startsWith('file+'))) {
return `${filename.substring(0, PNPM8_MAX_LENGTH_WITHOUT_HASH)}_${createBase32Hash(filename)}`;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoist regexes

Comment on lines +103 to +104
// In v9 lockfiles, aliased dependency values use the full package key format
// (e.g., 'string-width@4.2.3' or '@types/events@3.0.0') instead of bare versions.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This statement is true in v6 as well; that's why there's the specifier.startsWith('/') branch.
I wonder if we should just try to see if the value is a specifier by checking it against the packages list?

Comment on lines +128 to +138
if (key.startsWith('/')) {
// v6 format: /name@version or /@scope/name@version
name = key.slice(1, key.indexOf('@', 2));
} else if (!name) {
// v9 format: name@version or @scope/name@version
const searchStart: number = key.startsWith('@') ? key.indexOf('/') + 1 : 0;
const versionIndex: number = key.indexOf('@', searchStart);
if (versionIndex !== -1) {
name = key.slice(0, versionIndex);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only difference between these branches is the presence of the leading /, which just adjusts the offsets by 1. The !key.startsWith('/') branch can just return key.slice(0, key.indexOf('@', 1)) instead of key.slice(1, key.indexOf('@', 2))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

2 participants