diff --git a/common/changes/@microsoft/rush/fix-project-change-analyzer-nondefault-subspace-pnpmlock-check_2025-12-03-13-27.json b/common/changes/@microsoft/rush/fix-project-change-analyzer-nondefault-subspace-pnpmlock-check_2025-12-03-13-27.json new file mode 100644 index 00000000000..56156231f79 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-project-change-analyzer-nondefault-subspace-pnpmlock-check_2025-12-03-13-27.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix an issue where ProjectChangeAnalyzer checked the pnpm-lock.yaml file in the default subspace only, when it should consider all subspaces.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index ecf09749f5a..42b3566a497 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -17,6 +17,7 @@ import { import type { ITerminal } from '@rushstack/terminal'; import type { RushConfiguration } from '../api/RushConfiguration'; +import type { Subspace } from '../api/Subspace'; import { RushProjectConfiguration } from '../api/RushProjectConfiguration'; import type { RushConfigurationProject } from '../api/RushConfigurationProject'; import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile'; @@ -134,53 +135,76 @@ export class ProjectChangeAnalyzer { // Even though changing the installed version of a nested dependency merits a change file, // ignore lockfile changes for `rush change` for the moment + const subspaces: Iterable = rushConfiguration.subspacesFeatureEnabled + ? rushConfiguration.subspaces + : [rushConfiguration.defaultSubspace]; + const variantToUse: string | undefined = variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync()); - const fullShrinkwrapPath: string = - rushConfiguration.defaultSubspace.getCommittedShrinkwrapFilePath(variantToUse); - const relativeShrinkwrapFilePath: string = Path.convertToSlashes( - path.relative(repoRoot, fullShrinkwrapPath) - ); - const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath); + await Async.forEachAsync(subspaces, async (subspace: Subspace) => { + const fullShrinkwrapPath: string = subspace.getCommittedShrinkwrapFilePath(variantToUse); - if (shrinkwrapStatus) { - if (shrinkwrapStatus.status !== 'M') { - terminal.writeLine(`Lockfile was created or deleted. Assuming all projects are affected.`); - return new Set(rushConfiguration.projects); - } - - if (rushConfiguration.isPnpm) { - const currentShrinkwrap: PnpmShrinkwrapFile | undefined = - PnpmShrinkwrapFile.loadFromFile(fullShrinkwrapPath); + const relativeShrinkwrapFilePath: string = Path.convertToSlashes( + path.relative(repoRoot, fullShrinkwrapPath) + ); + const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath); + const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); - if (!currentShrinkwrap) { - throw new Error(`Unable to obtain current shrinkwrap file.`); + if (shrinkwrapStatus) { + if (shrinkwrapStatus.status !== 'M') { + if (rushConfiguration.subspacesFeatureEnabled) { + terminal.writeLine( + `"${subspace.subspaceName}" subspace lockfile was created or deleted. Assuming all projects are affected.` + ); + } else { + terminal.writeLine(`Lockfile was created or deleted. Assuming all projects are affected.`); + } + for (const project of subspaceProjects) { + changedProjects.add(project); + } + return; } - const oldShrinkwrapText: string = await this._git.getBlobContentAsync({ - // : syntax: https://git-scm.com/docs/gitrevisions - blobSpec: `${mergeCommit}:${relativeShrinkwrapFilePath}`, - repositoryRoot: repoRoot - }); - const oldShrinkWrap: PnpmShrinkwrapFile = PnpmShrinkwrapFile.loadFromString(oldShrinkwrapText); - - for (const project of rushConfiguration.projects) { - if ( - currentShrinkwrap - .getProjectShrinkwrap(project) - .hasChanges(oldShrinkWrap.getProjectShrinkwrap(project)) - ) { - changedProjects.add(project); + if (rushConfiguration.isPnpm) { + const currentShrinkwrap: PnpmShrinkwrapFile | undefined = + PnpmShrinkwrapFile.loadFromFile(fullShrinkwrapPath); + + if (!currentShrinkwrap) { + throw new Error(`Unable to obtain current shrinkwrap file.`); + } + + const oldShrinkwrapText: string = await this._git.getBlobContentAsync({ + // : syntax: https://git-scm.com/docs/gitrevisions + blobSpec: `${mergeCommit}:${relativeShrinkwrapFilePath}`, + repositoryRoot: repoRoot + }); + const oldShrinkWrap: PnpmShrinkwrapFile = PnpmShrinkwrapFile.loadFromString(oldShrinkwrapText); + + for (const project of subspaceProjects) { + if ( + currentShrinkwrap + .getProjectShrinkwrap(project) + .hasChanges(oldShrinkWrap.getProjectShrinkwrap(project)) + ) { + changedProjects.add(project); + } } + } else { + if (rushConfiguration.subspacesFeatureEnabled) { + terminal.writeLine( + `"${subspace.subspaceName}" subspace lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.` + ); + } else { + terminal.writeLine( + `Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.` + ); + } + subspace.getProjects().forEach((project) => changedProjects.add(project)); + return; } - } else { - terminal.writeLine( - `Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.` - ); - return new Set(rushConfiguration.projects); } - } + }); } return changedProjects; diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index 9ab196ea8a8..2667f16e360 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -41,6 +41,67 @@ jest.mock(`@rushstack/package-deps-hash`, () => { }, hashFilesAsync(rootDirectory: string, filePaths: Iterable): ReadonlyMap { return new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath])); + }, + getRepoChanges(): Map { + return new Map([ + [ + // Test subspace lockfile change detection + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-lock.yaml', + { + mode: 'modified', + newhash: 'newhash', + oldhash: 'oldhash', + status: 'M' + } + ], + [ + // Test lockfile deletion detection + 'common/config/subspaces/default/pnpm-lock.yaml', + { + mode: 'deleted', + newhash: '', + oldhash: 'oldhash', + status: 'D' + } + ] + ]); + } + }; +}); + +const { Git: OriginalGit } = jest.requireActual('../Git'); +/** Mock Git to test `getChangedProjectsAsync` */ +jest.mock('../Git', () => { + return { + Git: class MockGit extends OriginalGit { + public async determineIfRefIsACommitAsync(ref: string): Promise { + return true; + } + public async getMergeBaseAsync(ref1: string, ref2: string): Promise { + return 'merge-base-sha'; + } + public async getBlobContentAsync(opts: { blobSpec: string; repositoryRoot: string }): Promise { + return ''; + } + } + }; +}); + +const OriginalPnpmShrinkwrapFile = jest.requireActual('../pnpm/PnpmShrinkwrapFile').PnpmShrinkwrapFile; +jest.mock('../pnpm/PnpmShrinkwrapFile', () => { + return { + PnpmShrinkwrapFile: { + loadFromFile: (fullShrinkwrapPath: string): PnpmShrinkwrapFile => { + return OriginalPnpmShrinkwrapFile.loadFromString(_getMockedPnpmShrinkwrapFile()); + }, + loadFromString: (text: string): PnpmShrinkwrapFile => { + return OriginalPnpmShrinkwrapFile.loadFromString( + _getMockedPnpmShrinkwrapFile() + // Change dependencies version + .replace(/1\.0\.1/g, '1.0.0') + .replace(/foo_1_0_1/g, 'foo_1_0_0') + ); + } } }; }); @@ -55,7 +116,7 @@ jest.mock('../incremental/InputsSnapshot', () => { import { resolve } from 'node:path'; -import type { IDetailedRepoState } from '@rushstack/package-deps-hash'; +import type { IDetailedRepoState, IFileDiffStatus } from '@rushstack/package-deps-hash'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer'; @@ -65,6 +126,7 @@ import type { GetInputsSnapshotAsyncFn, IInputsSnapshotParameters } from '../incremental/InputsSnapshot'; +import type { PnpmShrinkwrapFile } from '../pnpm/PnpmShrinkwrapFile'; describe(ProjectChangeAnalyzer.name, () => { beforeEach(() => { @@ -101,4 +163,76 @@ describe(ProjectChangeAnalyzer.name, () => { expect(mockInput.additionalHashes).toEqual(new Map()); }); }); + + describe(ProjectChangeAnalyzer.prototype.getChangedProjectsAsync.name, () => { + it('Subspaces detects external changes', async () => { + const rootDir: string = resolve(__dirname, 'repoWithSubspaces'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: true, + targetBranchName: 'main', + terminal + }); + + // a,b,c is included because of change modifier is not modified + // d is included because its dependency foo version changed in the subspace lockfile + ['a', 'b', 'c', 'd'].forEach((projectName) => { + expect(changedProjects.has(rushConfiguration.getProjectByName(projectName)!)).toBe(true); + }); + + // e depends on d via workspace:*, but its calculated lockfile (e.g. "e/.rush/temp/shrinkwrap-deps.json") didn't change. + // So it's not included. e will be included by `expandConsumers` if needed. + ['e', 'f'].forEach((projectName) => { + expect(changedProjects.has(rushConfiguration.getProjectByName(projectName)!)).toBe(false); + }); + }); + }); }); + +/** + * Create a fake pnpm-lock.yaml content matches "libraries/rush-lib/src/logic/test/repoWithSubspaces" test repo + */ +function _getMockedPnpmShrinkwrapFile(): string { + return `lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: {} + + ../../../d: + dependencies: + foo: + specifier: ~1.0.0 + version: 1.0.1 + + ../../../e: + dependencies: + d: + specifier: workspace:* + version: link:../../../d + + ../../../f: + dependencies: + +packages: + + foo@1.0.1: + resolution: {integrity: 'foo_1_0_1'} + +snapshots: + + foo@1.0.1: {} +`; +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/a/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/a/package.json new file mode 100644 index 00000000000..e57f46f8473 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/a/package.json @@ -0,0 +1,5 @@ +{ + "name": "a", + "version": "1.0.0", + "description": "Test package a" +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/b/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/b/package.json new file mode 100644 index 00000000000..3a7fdf92a46 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/b/package.json @@ -0,0 +1,8 @@ +{ + "name": "b", + "version": "2.0.0", + "description": "Test package b", + "dependencies": { + "foo": "~1.0.0" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/c/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/c/package.json new file mode 100644 index 00000000000..84d308bd6c0 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/c/package.json @@ -0,0 +1,8 @@ +{ + "name": "c", + "version": "3.1.1", + "description": "Test package c", + "dependencies": { + "b": "workspace:*" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/experiments.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/experiments.json new file mode 100644 index 00000000000..a20c6d24388 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "exemptDecoupledDependenciesBetweenSubspaces": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..fdb1cb3ac59 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/pnpm-config.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "useWorkspaces": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/subspaces.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/subspaces.json new file mode 100644 index 00000000000..ab4e6b3a3c4 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/subspaces.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json", + "subspacesEnabled": true, + "subspaceNames": ["project-change-analyzer-test-subspace"] +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/version-policies.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/version-policies.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/rush/version-policies.json @@ -0,0 +1 @@ +[] diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/default/.pnpmfile.cjs b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/default/.pnpmfile.cjs new file mode 100644 index 00000000000..ee041f83a4e --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/default/.pnpmfile.cjs @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = { + hooks: { + readPackage(pkgJson) { + return pkgJson; + } + } +}; diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/default/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/default/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/default/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/project-change-analyzer-test-subspace/.pnpmfile.cjs b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/project-change-analyzer-test-subspace/.pnpmfile.cjs new file mode 100644 index 00000000000..ee041f83a4e --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/project-change-analyzer-test-subspace/.pnpmfile.cjs @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = { + hooks: { + readPackage(pkgJson) { + return pkgJson; + } + } +}; diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/project-change-analyzer-test-subspace/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/project-change-analyzer-test-subspace/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/common/config/subspaces/project-change-analyzer-test-subspace/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/d/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/d/package.json new file mode 100644 index 00000000000..fc4d11d2037 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/d/package.json @@ -0,0 +1,8 @@ +{ + "name": "d", + "version": "4.1.1", + "description": "Test package d", + "dependencies": { + "foo": "~1.0.0" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/e/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/e/package.json new file mode 100644 index 00000000000..c7210f01031 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/e/package.json @@ -0,0 +1,8 @@ +{ + "name": "e", + "version": "10.10.0", + "description": "Test package e", + "dependencies": { + "d": "workspace:*" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/f/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/f/package.json new file mode 100644 index 00000000000..5d6ea8a762e --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/f/package.json @@ -0,0 +1,5 @@ +{ + "name": "f", + "version": "10.10.0", + "description": "Test package f" +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspaces/rush.json b/libraries/rush-lib/src/logic/test/repoWithSubspaces/rush.json new file mode 100644 index 00000000000..895d07437cd --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspaces/rush.json @@ -0,0 +1,34 @@ +{ + "rushVersion": "1.0.5", + "pnpmVersion": "9.15.0", + + "projects": [ + { + "packageName": "a", + "projectFolder": "a" + }, + { + "packageName": "b", + "projectFolder": "b" + }, + { + "packageName": "c", + "projectFolder": "c" + }, + { + "packageName": "d", + "projectFolder": "d", + "subspaceName": "project-change-analyzer-test-subspace" + }, + { + "packageName": "e", + "projectFolder": "e", + "subspaceName": "project-change-analyzer-test-subspace" + }, + { + "packageName": "f", + "projectFolder": "f", + "subspaceName": "project-change-analyzer-test-subspace" + } + ] +}