From b7a4e04c0fd6a4b7e74889650df6e5ef96639ccd Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:32:00 +0000 Subject: [PATCH 1/6] fix(rush): normalize workspace-only pnpm v9 lockfiles Keep importer normalization active when a native workspace-only lockfile omits snapshots. Preserve earlier lockfile paths and the focused regressions. Original-Support-Commit: 8d508eec12c33c38a5573594148e9106c6c07e62 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- ...napshotless-lockfile_2026-10-12-05-00.json | 11 ++++ .../src/logic/pnpm/PnpmShrinkwrapFile.ts | 4 +- .../pnpm/test/PnpmShrinkwrapFile.test.ts | 54 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 common/changes/@microsoft/rush/pnpm12-snapshotless-lockfile_2026-10-12-05-00.json diff --git a/common/changes/@microsoft/rush/pnpm12-snapshotless-lockfile_2026-10-12-05-00.json b/common/changes/@microsoft/rush/pnpm12-snapshotless-lockfile_2026-10-12-05-00.json new file mode 100644 index 00000000000..e55ec9014c1 --- /dev/null +++ b/common/changes/@microsoft/rush/pnpm12-snapshotless-lockfile_2026-10-12-05-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Normalize PNPM v9 lockfile importers when a workspace-only or empty graph omits the snapshots section.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts index 686086cac89..6945bde6d69 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts @@ -426,7 +426,9 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { const { subspaceHasNoProjects } = options; const shrinkwrapJson: IPnpmShrinkwrapYaml = yamlModule.load(shrinkwrapContent) as IPnpmShrinkwrapYaml; - if ((shrinkwrapJson as LockfileFileV9).snapshots) { + const shrinkwrapFileMajorVersion: number = parseInt(String(shrinkwrapJson.lockfileVersion), 10); + // PNPM can omit snapshots when the lockfile contains only workspace links. + if (shrinkwrapFileMajorVersion >= ShrinkwrapFileMajorVersion.V9) { const lockfile: IPnpmShrinkwrapYaml | null = convertLockfileV9ToLockfileObject( shrinkwrapJson as LockfileFileV9 ); diff --git a/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts b/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts index 07a6f1b4e9d..e54732aa3b6 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts +++ b/libraries/rush-lib/src/logic/pnpm/test/PnpmShrinkwrapFile.test.ts @@ -16,6 +16,60 @@ const VERSION: string = '1.4.0'; const PRERELEASE_VERSION: string = '1.4.0-prerelease.0'; describe(PnpmShrinkwrapFile.name, () => { + describe('lockfiles without package snapshots', () => { + it.each(["'9.0'", '9.0'])( + 'normalizes workspace-only v9 importers with lockfileVersion %s', + (lockfileVersion: string) => { + const shrinkwrapFile: PnpmShrinkwrapFile = PnpmShrinkwrapFile.loadFromString( + ` +lockfileVersion: ${lockfileVersion} +importers: + .: {} + ../../packages/app: + dependencies: + library: + specifier: workspace:* + version: link:../library + devDependencies: + test-library: + specifier: workspace:* + version: link:../library + optionalDependencies: + optional-library: + specifier: workspace:* + version: link:../library + ../../packages/library: {} +`, + { subspaceHasNoProjects: false } + ); + + expect(shrinkwrapFile.getImporter('../../packages/app')).toEqual({ + dependencies: { library: 'link:../library' }, + devDependencies: { 'test-library': 'link:../library' }, + optionalDependencies: { 'optional-library': 'link:../library' }, + specifiers: { + library: 'workspace:*', + 'test-library': 'workspace:*', + 'optional-library': 'workspace:*' + } + }); + expect( + shrinkwrapFile.getIntegrityForImporter('../../packages/app')?.has('../../packages/library') + ).toBe(true); + } + ); + + it('normalizes the root importer of an empty v9 subspace', () => { + const shrinkwrapFile: PnpmShrinkwrapFile = PnpmShrinkwrapFile.loadFromString( + "lockfileVersion: '9.0'\nimporters:\n .: {}\n", + { subspaceHasNoProjects: true } + ); + + expect(shrinkwrapFile.isWorkspaceCompatible).toBe(true); + expect(shrinkwrapFile.getImporter('.')).toEqual({ specifiers: {} }); + }); + }); + describe(parsePnpmDependencyKey.name, () => { it('Does not support file:// specifiers', () => { const parsedSpecifier: DependencySpecifier | undefined = parsePnpmDependencyKey( From 4b7f4d38d48e5f01eee48d66eda2dd5b5a264b44 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:32:00 +0000 Subject: [PATCH 2/6] fix(rush): select native pnpm store environment variables Use native pnpm's prefix for store, cache and state while retaining older-manager precedence. Exercise the current branded parser through the existing controlled-test pattern instead of accessing removed TypeScript private fields. Original-Support-Commit: 840e38962d236834bb2cf962ca584a6aab2953cd Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- ...12-store-environment_2026-10-12-05-00.json | 11 +++ .../src/cli/RushPnpmCommandLineParser.ts | 26 +++++- .../test/RushPnpmCommandLineParser.test.ts | 88 +++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 common/changes/@microsoft/rush/pnpm12-store-environment_2026-10-12-05-00.json diff --git a/common/changes/@microsoft/rush/pnpm12-store-environment_2026-10-12-05-00.json b/common/changes/@microsoft/rush/pnpm12-store-environment_2026-10-12-05-00.json new file mode 100644 index 00000000000..70955863646 --- /dev/null +++ b/common/changes/@microsoft/rush/pnpm12-store-environment_2026-10-12-05-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Use PNPM 12-compatible store, cache, and state environment variables in rush-pnpm while preserving older PNPM versions and configured overrides.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index 3773929fac8..82502d67419 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -69,7 +69,7 @@ export interface IRushPnpmCommandLineParserOptions { } /** - * Controlled dependencies for exercising post-execution behavior in unit tests. + * Controlled dependencies for exercising execution and synchronization in unit tests. * @internal */ export interface IRushPnpmCommandLineParserTestOptions { @@ -266,6 +266,22 @@ export class RushPnpmCommandLineParser { await parser.#postExecuteAsync(); } + /** + * Exercises package-manager execution with controlled dependencies. + * @internal + */ + public static async _executeForTestingAsync( + testOptions: IRushPnpmCommandLineParserTestOptions + ): Promise { + const parser: RushPnpmCommandLineParser = new RushPnpmCommandLineParser( + {}, + testOptions.terminal, + false, + testOptions + ); + await parser.#executeAsync(); + } + public async executeAsync(): Promise { // Node.js can sometimes accidentally terminate with a zero exit code (e.g. for an uncaught // promise exception), so we start with the assumption that the exit code is 1 @@ -515,9 +531,11 @@ export class RushPnpmCommandLineParser { pnpmEnvironmentMap.set('NPM_CONFIG_WORKSPACE_DIR', workspaceFolder); if (rushConfiguration.pnpmOptions.pnpmStorePath) { - pnpmEnvironmentMap.set('NPM_CONFIG_STORE_DIR', rushConfiguration.pnpmOptions.pnpmStorePath); - pnpmEnvironmentMap.set('NPM_CONFIG_CACHE_DIR', rushConfiguration.pnpmOptions.pnpmStorePath); - pnpmEnvironmentMap.set('NPM_CONFIG_STATE_DIR', rushConfiguration.pnpmOptions.pnpmStorePath); + const pnpmConfigPrefix: string = + parseInt(rushConfiguration.packageManagerToolVersion, 10) >= 12 ? 'PNPM_CONFIG_' : 'NPM_CONFIG_'; + pnpmEnvironmentMap.set(`${pnpmConfigPrefix}STORE_DIR`, rushConfiguration.pnpmOptions.pnpmStorePath); + pnpmEnvironmentMap.set(`${pnpmConfigPrefix}CACHE_DIR`, rushConfiguration.pnpmOptions.pnpmStorePath); + pnpmEnvironmentMap.set(`${pnpmConfigPrefix}STATE_DIR`, rushConfiguration.pnpmOptions.pnpmStorePath); } if (rushConfiguration.pnpmOptions.environmentVariables) { diff --git a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts index fd3e8add8fe..3d9073e2825 100644 --- a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts @@ -9,6 +9,8 @@ import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import type { Subspace } from '../../api/Subspace'; import { RushPnpmCommandLineParser } from '../RushPnpmCommandLineParser'; +import { InstallHelpers } from '../../logic/installManager/InstallHelpers'; +import { Utilities, type IExecuteCommandOptions } from '../../utilities/Utilities'; async function validatePnpmArgsAsync(pnpmArgs: string[]): Promise { await RushPnpmCommandLineParser._validatePnpmUsageForTestingAsync(pnpmArgs); @@ -64,6 +66,92 @@ describe(RushPnpmCommandLineParser.name, () => { }); }); +describe(`${RushPnpmCommandLineParser.name} package manager environment`, () => { + const storePath: string = '/repo/common/temp/pnpm-store'; + const originalEnvironment: NodeJS.ProcessEnv = process.env; + const originalExitCode: typeof process.exitCode = process.exitCode; + + beforeEach(() => { + process.env = {}; + }); + + afterEach(() => { + process.env = originalEnvironment; + process.exitCode = originalExitCode; + jest.restoreAllMocks(); + }); + + async function executeAsync( + pnpmVersion: string, + environmentVariables?: Record + ): Promise { + const executeCommandAsync: jest.Mock = jest.fn().mockResolvedValue({ exitCode: 0, signal: null }); + jest.spyOn(Utilities, 'executeCommandAsync').mockImplementation(executeCommandAsync); + jest.spyOn(InstallHelpers, 'shouldProvideNpmrcCredentialsViaEnvironment').mockReturnValue(false); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${__dirname}/catalogSyncTestRepo/rush.json` + ); + Object.defineProperties(rushConfiguration, { + packageManagerToolFilename: { + value: '/repo/common/temp/pnpm-local/node_modules/.bin/pnpm', + configurable: true + }, + packageManagerToolVersion: { value: pnpmVersion, configurable: true } + }); + Object.defineProperties(rushConfiguration.pnpmOptions, { + pnpmStorePath: { value: storePath, configurable: true }, + environmentVariables: { value: environmentVariables, configurable: true } + }); + const subspace: Subspace = rushConfiguration.defaultSubspace; + jest.spyOn(subspace, 'getSubspaceTempFolderPath').mockReturnValue(SUBSPACE_TEMP_FOLDER); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); + const writeDebugLine: jest.SpyInstance = jest.spyOn(terminal, 'writeDebugLine'); + jest.spyOn(process, 'cwd').mockReturnValue('/repo/packages/app'); + await RushPnpmCommandLineParser._executeForTestingAsync({ + commandName: 'store', + doRushUpdateAsync: async () => {}, + rushConfiguration, + subspace, + terminal + }); + + expect(writeDebugLine).not.toHaveBeenCalled(); + expect(executeCommandAsync).toHaveBeenCalledTimes(1); + return executeCommandAsync.mock.calls[0][0]; + } + + it.each([ + ['8.14.0', 'NPM_CONFIG_'], + ['12.11.2', 'PNPM_CONFIG_'] + ])('sets the store and workspace for PNPM %s', async (pnpmVersion: string, prefix: string) => { + const { environment, workingDirectory } = await executeAsync(pnpmVersion); + + expect(environment?.[`${prefix}STORE_DIR`]).toBe(storePath); + expect(environment?.[`${prefix}CACHE_DIR`]).toBe(storePath); + expect(environment?.[`${prefix}STATE_DIR`]).toBe(storePath); + expect(environment?.NPM_CONFIG_WORKSPACE_DIR).toBe(SUBSPACE_TEMP_FOLDER); + expect(workingDirectory).toBe('/repo/packages/app'); + }); + + it.each([ + ['8.14.0', 'NPM_CONFIG_'], + ['12.11.2', 'PNPM_CONFIG_'] + ])( + 'preserves configured environment override precedence for PNPM %s', + async (pnpmVersion: string, prefix: string) => { + const { environment } = await executeAsync(pnpmVersion, { + [`${prefix}STORE_DIR`]: { value: '/custom/store', override: true }, + [`${prefix}CACHE_DIR`]: { value: '/ignored/cache', override: false }, + [`${prefix}STATE_DIR`]: { value: '/custom/state', override: true } + }); + + expect(environment?.[`${prefix}STORE_DIR`]).toBe('/custom/store'); + expect(environment?.[`${prefix}CACHE_DIR`]).toBe(storePath); + expect(environment?.[`${prefix}STATE_DIR`]).toBe('/custom/state'); + } + ); +}); + describe(`${RushPnpmCommandLineParser.name} catalog sync`, () => { const PACKAGE_ROOT: string = path.resolve(__dirname, '../../..'); const TEST_TEMP_FOLDER: string = `${PACKAGE_ROOT}/temp/rush-pnpm-catalog-sync-test`; From 2b12f9e756fda1d80add2fb8927c71a0b46792b4 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:32:00 +0000 Subject: [PATCH 3/6] fix(rush): retain defaults when pnpm ignores npmrc settings Original-Support-Commit: b76cc141b6be5652c3d55c8721aec0a4ba01fdcb Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- ...nored-npmrc-defaults_2026-10-12-05-00.json | 11 +++ .../src/logic/base/BaseInstallManager.ts | 33 +++++-- .../logic/pnpm/PnpmOptionsConfiguration.ts | 7 +- .../src/logic/test/BaseInstallManager.test.ts | 92 ++++++++++++++++++- 4 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 common/changes/@microsoft/rush/pnpm-ignored-npmrc-defaults_2026-10-12-05-00.json diff --git a/common/changes/@microsoft/rush/pnpm-ignored-npmrc-defaults_2026-10-12-05-00.json b/common/changes/@microsoft/rush/pnpm-ignored-npmrc-defaults_2026-10-12-05-00.json new file mode 100644 index 00000000000..c9e89af354e --- /dev/null +++ b/common/changes/@microsoft/rush/pnpm-ignored-npmrc-defaults_2026-10-12-05-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve Rush peer-installation and resolution defaults when PNPM 11 and newer ignore obsolete .npmrc settings, and explain how to configure those settings.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index a515a84a258..489b55e3df0 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -953,10 +953,13 @@ ${gitLfsHookHandling} args.push('--resolution-only'); } + const pnpmVersion: string = this.rushConfiguration.packageManagerToolVersion; + const supportsNpmrcSettings: boolean = semver.lt(pnpmVersion, '11.0.0'); + /* If user set auto-install-peers in pnpm-config.json only, use the value in pnpm-config.json If user set auto-install-peers in pnpm-config.json and .npmrc, use the value in pnpm-config.json - If user set auto-install-peers in .npmrc only, do nothing, let pnpm handle it + For PNPM 10 and earlier, if user set auto-install-peers in .npmrc only, let pnpm handle it If user does not set auto-install-peers in both pnpm-config.json and .npmrc, rush will default it to "false" */ const isAutoInstallPeersInNpmrc: boolean = isVariableSetInNpmrcFile( @@ -965,16 +968,23 @@ ${gitLfsHookHandling} this.rushConfiguration.isPnpm ); + if (isAutoInstallPeersInNpmrc && !supportsNpmrcSettings) { + this._terminal.writeWarningLine( + `PNPM ${pnpmVersion} ignores "auto-install-peers" in .npmrc. ` + + 'Set "autoInstallPeers" in pnpm-config.json instead.' + ); + } + let autoInstallPeers: boolean | undefined = this.rushConfiguration.pnpmOptions.autoInstallPeers; if (autoInstallPeers !== undefined) { - if (isAutoInstallPeersInNpmrc) { + if (isAutoInstallPeersInNpmrc && supportsNpmrcSettings) { this._terminal.writeWarningLine( `Warning: PNPM's auto-install-peers is specified in both .npmrc and pnpm-config.json. ` + `The value in pnpm-config.json will take precedence.` ); } - } else if (!isAutoInstallPeersInNpmrc) { - // if auto-install-peers isn't specified in either .npmrc or pnpm-config.json, + } else if (!isAutoInstallPeersInNpmrc || !supportsNpmrcSettings) { + // If auto-install-peers isn't specified in a supported configuration, // then rush will default it to "false" autoInstallPeers = false; } @@ -985,7 +995,7 @@ ${gitLfsHookHandling} /* If user set resolution-mode in pnpm-config.json only, use the value in pnpm-config.json If user set resolution-mode in pnpm-config.json and .npmrc, use the value in pnpm-config.json - If user set resolution-mode in .npmrc only, do nothing, let pnpm handle it + For PNPM 10 and earlier, if user set resolution-mode in .npmrc only, let pnpm handle it If user does not set resolution-mode in pnpm-config.json and .npmrc, rush will default it to "highest" */ const isResolutionModeInNpmrc: boolean = isVariableSetInNpmrcFile( @@ -994,16 +1004,23 @@ ${gitLfsHookHandling} this.rushConfiguration.isPnpm ); + if (isResolutionModeInNpmrc && !supportsNpmrcSettings) { + this._terminal.writeWarningLine( + `PNPM ${pnpmVersion} ignores "resolution-mode" in .npmrc. ` + + 'Set "resolutionMode" in pnpm-config.json instead.' + ); + } + let resolutionMode: PnpmResolutionMode | undefined = this.rushConfiguration.pnpmOptions.resolutionMode; if (resolutionMode) { - if (isResolutionModeInNpmrc) { + if (isResolutionModeInNpmrc && supportsNpmrcSettings) { this._terminal.writeWarningLine( `Warning: PNPM's resolution-mode is specified in both .npmrc and pnpm-config.json. ` + `The value in pnpm-config.json will take precedence.` ); } - } else if (!isResolutionModeInNpmrc) { - // if resolution-mode isn't specified in either .npmrc or pnpm-config.json, + } else if (!isResolutionModeInNpmrc || !supportsNpmrcSettings) { + // If resolution-mode isn't specified in a supported configuration, // then rush will default it to "highest" resolutionMode = 'highest'; } diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 79d38da2b7c..898699e671c 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -242,8 +242,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration * IMPORTANT: Be aware that PNPM 8.0.0 initially defaulted to `lowest-direct` instead of * `highest`, but PNPM reverted this decision in 8.6.12 because it caused confusion for users. * Rush version 5.106.0 and newer avoids this confusion by consistently defaulting to - * `highest` when `resolutionMode` is not explicitly set in pnpm-config.json or .npmrc, - * regardless of your PNPM version. + * `highest` when `resolutionMode` is not explicitly set in pnpm-config.json or, for PNPM 10 + * and earlier, .npmrc. PNPM 11 and newer ignore this setting in .npmrc. * * PNPM documentation: https://pnpm.io/npmrc#resolution-mode * @@ -303,7 +303,8 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration * When true, any missing non-optional peer dependencies are automatically installed. * * @remarks - * The default value is same as PNPM default value. (In PNPM 8.x, this value is true) + * Rush defaults to false unless explicitly configured. PNPM 10 and earlier can also read + * this setting from .npmrc; PNPM 11 and newer require this property instead. */ public readonly autoInstallPeers: boolean | undefined; diff --git a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts index 7ecf61071f4..a15ec340667 100644 --- a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts +++ b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts @@ -2,7 +2,12 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; -import { ConsoleTerminalProvider, type ITerminal, Terminal } from '@rushstack/terminal'; +import { + ConsoleTerminalProvider, + type ITerminal, + StringBufferTerminalProvider, + Terminal +} from '@rushstack/terminal'; import { PurgeManager } from '../PurgeManager'; import { BaseInstallManager, pnpmIgnoreCompatibilityDbParameter } from '../base/BaseInstallManager'; @@ -11,6 +16,8 @@ import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; import { RushConfiguration } from '../../api/RushConfiguration'; import { RushGlobalFolder } from '../../api/RushGlobalFolder'; import type { Subspace } from '../../api/Subspace'; +import type { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; +import * as npmrcUtilities from '../../utilities/npmrcUtilities'; class FakeBaseInstallManager extends BaseInstallManager { public constructor( @@ -135,3 +142,86 @@ describe('BaseInstallManager Test', () => { } }); }); + +describe('PNPM npmrc configuration precedence', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + function getConfigurationArgs( + pnpmVersion: string, + npmrcSettings: boolean, + pnpmOptions: Partial> = {} + ): { args: string[]; warnings: string } { + jest.spyOn(npmrcUtilities, 'isVariableSetInNpmrcFile').mockReturnValue(npmrcSettings); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const manager: FakeBaseInstallManager = Object.create(FakeBaseInstallManager.prototype); + Object.assign(manager, { + rushConfiguration: { + packageManager: 'pnpm', + isPnpm: true, + packageManagerToolVersion: pnpmVersion, + pnpmOptions: { pnpmStore: 'global', pnpmStorePath: '/repo/common/temp/pnpm-store', ...pnpmOptions }, + experimentsConfiguration: { configuration: {} } + }, + _terminal: new Terminal(terminalProvider) + }); + const args: string[] = []; + manager.pushConfigurationArgs( + args, + { pnpmFilterArgumentValues: [] } as unknown as IInstallManagerOptions, + { getSubspaceConfigFolderPath: () => '/repo/common/config/rush' } as Subspace + ); + return { args, warnings: terminalProvider.getWarningOutput() }; + } + + it.each(['8.14.0', '12.11.2'])( + 'uses Rush defaults when settings are absent with PNPM %s', + (pnpmVersion: string) => { + const { args, warnings } = getConfigurationArgs(pnpmVersion, false); + + expect(args).toContain('--config.auto-install-peers=false'); + expect(args).toContain('--config.resolutionMode=highest'); + expect(warnings).toBe(''); + } + ); + + it('lets PNPM 8 consume settings from npmrc', () => { + const { args, warnings } = getConfigurationArgs('8.14.0', true); + + expect(args.some((arg) => arg.startsWith('--config.auto-install-peers='))).toBe(false); + expect(args.some((arg) => arg.startsWith('--config.resolutionMode='))).toBe(false); + expect(warnings).toBe(''); + }); + + it.each(['11.0.0', '12.11.2'])( + 'does not let ignored npmrc settings suppress defaults with PNPM %s', + (pnpmVersion: string) => { + const { args, warnings } = getConfigurationArgs(pnpmVersion, true); + + expect(args).toContain('--config.auto-install-peers=false'); + expect(args).toContain('--config.resolutionMode=highest'); + expect(warnings).toContain('ignores "auto-install-peers" in .npmrc'); + expect(warnings).toContain('ignores "resolution-mode" in .npmrc'); + expect(warnings).toContain('pnpm-config.json'); + } + ); + + it.each([ + ['8.14.0', true], + ['8.14.0', false], + ['12.11.2', true], + ['12.11.2', false] + ])( + 'preserves explicit PNPM %s configuration with autoInstallPeers=%s', + (pnpmVersion: string, autoInstallPeers: boolean) => { + const { args } = getConfigurationArgs(pnpmVersion, true, { + autoInstallPeers, + resolutionMode: 'lowest-direct' + }); + + expect(args).toContain(`--config.auto-install-peers=${autoInstallPeers}`); + expect(args).toContain('--config.resolutionMode=lowest-direct'); + } + ); +}); From fd40fc4f029adcf39edcdb9b76a5289ff4772cef Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:32:00 +0000 Subject: [PATCH 4/6] fix(rush): reject removed pnpm resolution-only mode early Original-Support-Commit: 33003805b0d30348beef651ce019b70d11a48221 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- ...pm12-resolution-only_2026-10-12-05-00.json | 11 +++ .../rush-lib/src/cli/actions/InstallAction.ts | 10 ++- .../src/cli/test/InstallAction.test.ts | 74 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 common/changes/@microsoft/rush/pnpm12-resolution-only_2026-10-12-05-00.json create mode 100644 libraries/rush-lib/src/cli/test/InstallAction.test.ts diff --git a/common/changes/@microsoft/rush/pnpm12-resolution-only_2026-10-12-05-00.json b/common/changes/@microsoft/rush/pnpm12-resolution-only_2026-10-12-05-00.json new file mode 100644 index 00000000000..84a132033c0 --- /dev/null +++ b/common/changes/@microsoft/rush/pnpm12-resolution-only_2026-10-12-05-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Reject the unsupported --resolution-only option before installation when using PNPM 12 or newer, with guidance for supported diagnostics and update workflows.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/cli/actions/InstallAction.ts b/libraries/rush-lib/src/cli/actions/InstallAction.ts index 02869db9eea..ae965882e9a 100644 --- a/libraries/rush-lib/src/cli/actions/InstallAction.ts +++ b/libraries/rush-lib/src/cli/actions/InstallAction.ts @@ -53,12 +53,20 @@ export class InstallAction extends BaseInstallAction { if (this.rushConfiguration?.isPnpm) { this.#resolutionOnlyParameter = this.defineFlagParameter({ parameterLongName: '--resolution-only', - description: `Only perform dependency resolution, useful for ensuring peer dependendencies are up to date. Note that this flag is only supported when using the pnpm package manager.` + description: `Only perform dependency resolution, useful for ensuring peer dependencies are up to date. This flag is only supported with PNPM versions earlier than 12.` }); } } protected async buildInstallOptionsAsync(): Promise> { + const pnpmVersion: string = this.rushConfiguration.packageManagerToolVersion; + if (this.#resolutionOnlyParameter?.value && parseInt(pnpmVersion, 10) >= 12) { + throw new Error( + `The "--resolution-only" parameter is not supported with PNPM ${pnpmVersion}. ` + + 'Use "rush-pnpm why " to inspect dependencies, or run "rush update" to update the lockfile.' + ); + } + const selectedProjects: Set = (await this._selectionParameters?.getSelectedProjectsAsync(this.terminal)) ?? new Set(this.rushConfiguration.projects); diff --git a/libraries/rush-lib/src/cli/test/InstallAction.test.ts b/libraries/rush-lib/src/cli/test/InstallAction.test.ts new file mode 100644 index 00000000000..31bc2971f24 --- /dev/null +++ b/libraries/rush-lib/src/cli/test/InstallAction.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { RushConfiguration } from '../../api/RushConfiguration'; +import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; +import type { IInstallManagerOptions } from '../../logic/base/BaseInstallManagerTypes'; +import { InstallAction } from '../actions/InstallAction'; +import type { RushCommandLineParser } from '../RushCommandLineParser'; + +class TestInstallAction extends InstallAction { + public override buildInstallOptionsAsync(): Promise> { + return super.buildInstallOptionsAsync(); + } +} + +describe(InstallAction.name, () => { + function createAction( + pnpmVersion: string, + resolutionOnly: boolean + ): { action: TestInstallAction; selectProjects: jest.Mock } { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${__dirname}/catalogSyncTestRepo/rush.json` + ); + Object.defineProperty(rushConfiguration, 'packageManagerToolVersion', { value: pnpmVersion }); + const action: TestInstallAction = new TestInstallAction({ + rushConfiguration, + cwd: rushConfiguration.rushJsonFolder, + terminal: new Terminal(new StringBufferTerminalProvider()), + rushSession: { getReporter: () => undefined } + } as unknown as RushCommandLineParser); + Object.defineProperty(action.getFlagParameter('--resolution-only'), 'value', { value: resolutionOnly }); + + const selectProjects: jest.Mock = jest.fn().mockResolvedValue(new Set()); + Object.assign(action, { + _selectionParameters: { + getSelectedProjectsAsync: selectProjects, + getPnpmFilterArgumentValuesAsync: async () => [] + } + }); + return { action, selectProjects }; + } + + it('rejects PNPM 12 resolution-only before project selection or installation', async () => { + const { action, selectProjects } = createAction('12.11.2', true); + + await expect(action.buildInstallOptionsAsync()).rejects.toThrow( + 'The "--resolution-only" parameter is not supported with PNPM 12.11.2. ' + + 'Use "rush-pnpm why " to inspect dependencies, or run "rush update" to update the lockfile.' + ); + expect(selectProjects).not.toHaveBeenCalled(); + }); + + it('preserves PNPM 8 resolution-only behavior', async () => { + const { action, selectProjects } = createAction('8.14.0', true); + + await expect(action.buildInstallOptionsAsync()).resolves.toMatchObject({ + resolutionOnly: true, + allowShrinkwrapUpdates: false + }); + expect(selectProjects).toHaveBeenCalledTimes(1); + }); + + it('allows ordinary PNPM 12 installs', async () => { + const { action, selectProjects } = createAction('12.11.2', false); + + await expect(action.buildInstallOptionsAsync()).resolves.toMatchObject({ + resolutionOnly: false, + allowShrinkwrapUpdates: false + }); + expect(selectProjects).toHaveBeenCalledTimes(1); + }); +}); From 2abe3bcc5411f9e033180997cc9f19ba297e1b77 Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:32:00 +0000 Subject: [PATCH 5/6] fix(rush): explicitly disable native pnpm compatibility extensions Preserve the existing compatibility database policy with an explicit native boolean, without changing legacy pnpm arguments. Original-Support-Commit: acfc4d4a9956e1f3635b000b66c3f54c204a33e8 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bedb50cb-fcd4-4dc4-83b9-dba6064e5498 --- ...t-compatibility-flag_2026-10-12-20-00.json | 10 ++++++++++ .../src/logic/base/BaseInstallManager.ts | 7 ++++++- .../src/logic/test/BaseInstallManager.test.ts | 19 ++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/pnpm12-explicit-compatibility-flag_2026-10-12-20-00.json diff --git a/common/changes/@microsoft/rush/pnpm12-explicit-compatibility-flag_2026-10-12-20-00.json b/common/changes/@microsoft/rush/pnpm12-explicit-compatibility-flag_2026-10-12-20-00.json new file mode 100644 index 00000000000..d05e06194c9 --- /dev/null +++ b/common/changes/@microsoft/rush/pnpm12-explicit-compatibility-flag_2026-10-12-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Pass an explicit value when disabling the compatibility database with native PNPM, preventing unintended package extensions and peer dependency changes.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index 489b55e3df0..3224f20d1c3 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -1045,7 +1045,12 @@ ${gitLfsHookHandling} semver.gte(this.rushConfiguration.packageManagerToolVersion, '7.9.0') || semver.satisfies(this.rushConfiguration.packageManagerToolVersion, '^6.34.0') ) { - args.push(pnpmIgnoreCompatibilityDbParameter); + // Native PNPM does not interpret a custom config flag without a value as true. + args.push( + semver.gte(pnpmVersion, '12.0.0') + ? `${pnpmIgnoreCompatibilityDbParameter}=true` + : pnpmIgnoreCompatibilityDbParameter + ); } } else if (this.rushConfiguration.packageManager === 'yarn') { args.push('--link-folder', 'yarn-link'); diff --git a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts index a15ec340667..a42f25a86ad 100644 --- a/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts +++ b/libraries/rush-lib/src/logic/test/BaseInstallManager.test.ts @@ -143,7 +143,7 @@ describe('BaseInstallManager Test', () => { }); }); -describe('PNPM npmrc configuration precedence', () => { +describe('PNPM configuration arguments', () => { afterEach(() => { jest.restoreAllMocks(); }); @@ -186,6 +186,23 @@ describe('PNPM npmrc configuration precedence', () => { } ); + it.each(['8.14.0', '10.27.0', '11.0.0'])( + 'preserves the compatibility database flag for PNPM %s', + (pnpmVersion: string) => { + const { args } = getConfigurationArgs(pnpmVersion, false); + + expect(args).toContain('--config.ignoreCompatibilityDb'); + expect(args).not.toContain('--config.ignoreCompatibilityDb=true'); + } + ); + + it('explicitly disables the compatibility database for native PNPM', () => { + const { args } = getConfigurationArgs('12.11.2', false); + + expect(args).toContain('--config.ignoreCompatibilityDb=true'); + expect(args).not.toContain('--config.ignoreCompatibilityDb'); + }); + it('lets PNPM 8 consume settings from npmrc', () => { const { args, warnings } = getConfigurationArgs('8.14.0', true); From d5fb75af854d435d8e2c92bf94a6dc037c44674a Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:31:36 -0700 Subject: [PATCH 6/6] test(rush): update --resolution-only help snapshots Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/api/test/__snapshots__/RushCommandLine.test.ts.snap | 2 +- .../src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap index d913bb774e3..9858a6bbf44 100644 --- a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap +++ b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap @@ -495,7 +495,7 @@ Object { "shortName": undefined, }, Object { - "description": "Only perform dependency resolution, useful for ensuring peer dependendencies are up to date. Note that this flag is only supported when using the pnpm package manager.", + "description": "Only perform dependency resolution, useful for ensuring peer dependencies are up to date. This flag is only supported with PNPM versions earlier than 12.", "environmentVariable": undefined, "kind": "Flag", "longName": "--resolution-only", diff --git a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap index bed4fdd30af..0d738cff90c 100644 --- a/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap +++ b/libraries/rush-lib/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap @@ -848,9 +848,8 @@ Optional arguments: --check-only Only check the validity of the shrinkwrap file without performing an install. --resolution-only Only perform dependency resolution, useful for - ensuring peer dependendencies are up to date. Note - that this flag is only supported when using the pnpm - package manager. + ensuring peer dependencies are up to date. This flag + is only supported with PNPM versions earlier than 12. " `;