From 3a3333ecfa8d17bbc499940daf5d88148c7d7401 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Tue, 17 Feb 2026 13:43:42 -0500 Subject: [PATCH 1/9] add pnpm global catalog detection to rush change --- .../src/logic/ProjectChangeAnalyzer.ts | 104 ++++++++++ .../logic/test/ProjectChangeAnalyzer.test.ts | 194 ++++++++++++++++++ .../test/repoWithCatalogs/a/package.json | 9 + .../test/repoWithCatalogs/b/package.json | 11 + .../test/repoWithCatalogs/c/package.json | 5 + .../common/config/rush/pnpm-config.json | 12 ++ .../src/logic/test/repoWithCatalogs/rush.json | 19 ++ 7 files changed, 354 insertions(+) create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/a/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/b/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/c/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 77ded049ca..8d5237674a 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -23,6 +23,9 @@ import type { RushConfigurationProject } from '../api/RushConfigurationProject'; import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile'; import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile'; import { Git } from './Git'; +import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier'; +import { RushConstants } from './RushConstants'; +import type { IPnpmOptionsJson } from './pnpm/PnpmOptionsConfiguration'; import { type IInputsSnapshotProjectMetadata, type IInputsSnapshot, @@ -178,6 +181,107 @@ export class ProjectChangeAnalyzer { { concurrency: 10 } ); + // Detect changes to pnpm catalog entries in pnpm-config.json + if (rushConfiguration.isPnpm && rushConfiguration.pnpmOptions.globalCatalogs) { + const pnpmConfigRelativePath: string = Path.convertToSlashes( + path.relative( + repoRoot, + path.join(rushConfiguration.commonRushConfigFolder, RushConstants.pnpmConfigFilename) + ) + ); + + if (changedFiles.has(pnpmConfigRelativePath)) { + const currentCatalogs: Record> = + rushConfiguration.pnpmOptions.globalCatalogs; + + // Determine which catalog names have changed + let changedCatalogNames: Set; + try { + const oldPnpmConfigText: string = await this._git.getBlobContentAsync({ + blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`, + repositoryRoot: repoRoot + }); + const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText); + const oldCatalogs: Record> = + oldPnpmConfig.globalCatalogs ?? {}; + + changedCatalogNames = new Set(); + + // Check current catalogs for new or modified entries + for (const [catalogName, packages] of Object.entries(currentCatalogs)) { + const oldPackages: Record | undefined = oldCatalogs[catalogName]; + if (!oldPackages) { + changedCatalogNames.add(catalogName); + continue; + } + for (const [pkgName, version] of Object.entries(packages)) { + if (oldPackages[pkgName] !== version) { + changedCatalogNames.add(catalogName); + break; + } + } + } + + // Check for catalogs that were removed + for (const catalogName of Object.keys(oldCatalogs)) { + if (!(catalogName in currentCatalogs)) { + changedCatalogNames.add(catalogName); + } + } + } catch { + // Old file didn't exist or was unparseable — treat all current catalogs as changed + changedCatalogNames = new Set(Object.keys(currentCatalogs)); + } + + if (changedCatalogNames.size > 0) { + // Build a map of catalogName → Set + const catalogToProjects: Map> = new Map(); + for (const project of rushConfiguration.projects) { + const { dependencies, devDependencies, optionalDependencies } = project.packageJson; + const allDeps: Record[] = [ + dependencies ?? {}, + devDependencies ?? {}, + optionalDependencies ?? {} + ]; + + for (const deps of allDeps) { + for (const [depName, depVersion] of Object.entries(deps)) { + const specifier: DependencySpecifier = DependencySpecifier.parseWithCache( + depName, + depVersion + ); + if (specifier.specifierType === DependencySpecifierType.Catalog) { + // versionSpecifier holds the catalog name (empty string for "catalog:") + const catalogName: string = specifier.versionSpecifier || 'default'; + let projectSet: Set | undefined = + catalogToProjects.get(catalogName); + if (!projectSet) { + projectSet = new Set(); + catalogToProjects.set(catalogName, projectSet); + } + projectSet.add(project); + } + } + } + } + + // Mark projects using changed catalogs (and their direct consumers) as changed + for (const catalogName of changedCatalogNames) { + const affectedProjects: Set | undefined = + catalogToProjects.get(catalogName); + if (affectedProjects) { + for (const project of affectedProjects) { + changedProjects.add(project); + for (const consumer of project.consumingProjects) { + changedProjects.add(consumer); + } + } + } + } + } + } + } + // External dependency changes are not allowed to be filtered, so add these after filtering if (includeExternalDependencies) { // Even though changing the installed version of a nested dependency merits a change file, diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index 7be396977c..73e6d54323 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -583,6 +583,200 @@ describe(ProjectChangeAnalyzer.name, () => { }); expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); }); + + describe('catalog change detection', () => { + it('detects projects using a changed catalog entry', async () => { + const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // pnpm-config.json changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/rush/pnpm-config.json', + { + mode: 'modified', + newhash: 'newhash', + oldhash: 'oldhash', + status: 'M' + } + ] + ]) + ); + + // Old config had react ^17.0.0, now it's ^18.0.0 + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { + default: { + react: '^17.0.0', + lodash: '^4.17.21' + }, + tools: { + typescript: '~5.3.0' + } + } + }) + ); + }); + + 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: false, + targetBranchName: 'main', + terminal + }); + + // Project 'a' uses catalog:default (react changed) + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + }); + + it('does not detect projects using an unchanged catalog', async () => { + const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // pnpm-config.json changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/rush/pnpm-config.json', + { + mode: 'modified', + newhash: 'newhash', + oldhash: 'oldhash', + status: 'M' + } + ] + ]) + ); + + // Only the tools catalog changed (typescript version), default catalog is identical + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { + default: { + react: '^18.0.0', + lodash: '^4.17.21' + }, + tools: { + typescript: '~5.2.0' + } + } + }) + ); + }); + + 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: false, + targetBranchName: 'main', + terminal + }); + + // Project 'b' uses catalog:tools (typescript changed) + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + // Project 'a' uses catalog:default (unchanged) + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + // Project 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + + it('treats all catalogs as changed when old pnpm-config.json does not exist', async () => { + const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // pnpm-config.json was newly created + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/rush/pnpm-config.json', + { + mode: 'added', + newhash: 'newhash', + oldhash: '', + status: 'A' + } + ] + ]) + ); + + // Simulate file not existing in old commit + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.reject(new Error('fatal: path not found')); + }); + + 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: false, + targetBranchName: 'main', + terminal + }); + + // All catalog-using projects should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + // Project 'c' has no catalog deps, still not detected + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + + it('does not detect additional projects when pnpm-config.json is not changed', async () => { + const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // Only a source file changed, not pnpm-config.json + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'c/src/index.ts', + { + mode: 'modified', + newhash: 'newhash', + oldhash: 'oldhash', + status: 'M' + } + ] + ]) + ); + + 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: false, + targetBranchName: 'main', + terminal + }); + + // Only project 'c' should be detected (direct file change) + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(true); + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + }); + }); }); describe('isPackageJsonVersionOnlyChange', () => { diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/a/package.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/a/package.json new file mode 100644 index 0000000000..26e2a7fe95 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/a/package.json @@ -0,0 +1,9 @@ +{ + "name": "a", + "version": "1.0.0", + "description": "Project A uses default catalog", + "dependencies": { + "react": "catalog:", + "lodash": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/b/package.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/b/package.json new file mode 100644 index 0000000000..1f535dd854 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/b/package.json @@ -0,0 +1,11 @@ +{ + "name": "b", + "version": "1.0.0", + "description": "Project B depends on A and uses tools catalog", + "dependencies": { + "a": "workspace:*" + }, + "devDependencies": { + "typescript": "catalog:tools" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/c/package.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/c/package.json new file mode 100644 index 0000000000..ea46bdf7c6 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/c/package.json @@ -0,0 +1,5 @@ +{ + "name": "c", + "version": "1.0.0", + "description": "Project C has no catalog dependencies" +} diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 0000000000..a8bee68efa --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "globalCatalogs": { + "default": { + "react": "^18.0.0", + "lodash": "^4.17.21" + }, + "tools": { + "typescript": "~5.3.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json new file mode 100644 index 0000000000..01cf3a4cb5 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json @@ -0,0 +1,19 @@ +{ + "pnpmVersion": "9.15.0", + "rushVersion": "1.0.5", + + "projects": [ + { + "packageName": "a", + "projectFolder": "a" + }, + { + "packageName": "b", + "projectFolder": "b" + }, + { + "packageName": "c", + "projectFolder": "c" + } + ] +} From 363cdddd437c312d27abe222024e73fdeae9acd0 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Tue, 17 Feb 2026 13:49:18 -0500 Subject: [PATCH 2/9] changelog --- .../rush-change-catalog-entries_2026-02-17-18-48.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json diff --git a/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json b/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json new file mode 100644 index 0000000000..8f2aed8eff --- /dev/null +++ b/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "add pnpm global catalog detection to rush change", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file From 0f3ecd830c359038eb4e195b798261fecbd1ea26 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Tue, 17 Feb 2026 13:58:07 -0500 Subject: [PATCH 3/9] remove consumers marked as changed --- .../rush-lib/src/logic/ProjectChangeAnalyzer.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 8d5237674a..3023329e3e 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -191,8 +191,8 @@ export class ProjectChangeAnalyzer { ); if (changedFiles.has(pnpmConfigRelativePath)) { - const currentCatalogs: Record> = - rushConfiguration.pnpmOptions.globalCatalogs; + const currentCatalogs: Record> = rushConfiguration.pnpmOptions + .globalCatalogs; // Determine which catalog names have changed let changedCatalogNames: Set; @@ -202,8 +202,7 @@ export class ProjectChangeAnalyzer { repositoryRoot: repoRoot }); const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText); - const oldCatalogs: Record> = - oldPnpmConfig.globalCatalogs ?? {}; + const oldCatalogs: Record> = oldPnpmConfig.globalCatalogs ?? {}; changedCatalogNames = new Set(); @@ -265,16 +264,13 @@ export class ProjectChangeAnalyzer { } } - // Mark projects using changed catalogs (and their direct consumers) as changed + // Mark projects using changed catalogs as changed for (const catalogName of changedCatalogNames) { const affectedProjects: Set | undefined = catalogToProjects.get(catalogName); if (affectedProjects) { for (const project of affectedProjects) { changedProjects.add(project); - for (const consumer of project.consumingProjects) { - changedProjects.add(consumer); - } } } } From 49ef791a75734136b19a683ef50571745cca7664 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Tue, 17 Feb 2026 14:03:02 -0500 Subject: [PATCH 4/9] add additional test --- .../logic/test/ProjectChangeAnalyzer.test.ts | 184 ++++++------------ 1 file changed, 58 insertions(+), 126 deletions(-) diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index 73e6d54323..09e9cad8bd 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -585,47 +585,40 @@ describe(ProjectChangeAnalyzer.name, () => { }); describe('catalog change detection', () => { - it('detects projects using a changed catalog entry', async () => { - const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); - const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( - resolve(rootDir, 'rush.json') + function getCatalogRushConfiguration(): RushConfiguration { + return RushConfiguration.loadFromConfigurationFile( + resolve(__dirname, 'repoWithCatalogs', 'rush.json') ); + } - // pnpm-config.json changed + function mockPnpmConfigChanged(): void { mockGetRepoChanges.mockReturnValue( new Map([ [ 'common/config/rush/pnpm-config.json', - { - mode: 'modified', - newhash: 'newhash', - oldhash: 'oldhash', - status: 'M' - } + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } ] ]) ); + } - // Old config had react ^17.0.0, now it's ^18.0.0 + function mockOldCatalogs(catalogs: Record>): void { mockGetBlobContentAsync.mockImplementation(() => { - return Promise.resolve( - JSON.stringify({ - globalCatalogs: { - default: { - react: '^17.0.0', - lodash: '^4.17.21' - }, - tools: { - typescript: '~5.3.0' - } - } - }) - ); + return Promise.resolve(JSON.stringify({ globalCatalogs: catalogs })); + }); + } + + it('detects change to default catalog', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // react version bumped in the default catalog + mockOldCatalogs({ + default: { react: '^17.0.0', lodash: '^4.17.21' }, + tools: { typescript: '~5.3.0' } }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); - const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); - const terminal: Terminal = new Terminal(terminalProvider); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ enableFiltering: false, @@ -634,51 +627,25 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // Project 'a' uses catalog:default (react changed) + // 'a' uses "catalog:" (default) for react and lodash expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + // 'b' uses "catalog:tools" only — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); - it('does not detect projects using an unchanged catalog', async () => { - const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); - const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( - resolve(rootDir, 'rush.json') - ); - - // pnpm-config.json changed - mockGetRepoChanges.mockReturnValue( - new Map([ - [ - 'common/config/rush/pnpm-config.json', - { - mode: 'modified', - newhash: 'newhash', - oldhash: 'oldhash', - status: 'M' - } - ] - ]) - ); - - // Only the tools catalog changed (typescript version), default catalog is identical - mockGetBlobContentAsync.mockImplementation(() => { - return Promise.resolve( - JSON.stringify({ - globalCatalogs: { - default: { - react: '^18.0.0', - lodash: '^4.17.21' - }, - tools: { - typescript: '~5.2.0' - } - } - }) - ); + it('detects change to named catalog', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // typescript version bumped in the tools catalog + mockOldCatalogs({ + default: { react: '^18.0.0', lodash: '^4.17.21' }, + tools: { typescript: '~5.2.0' } }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); - const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); - const terminal: Terminal = new Terminal(terminalProvider); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ enableFiltering: false, @@ -687,43 +654,25 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // Project 'b' uses catalog:tools (typescript changed) + // 'b' uses "catalog:tools" for typescript expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); - // Project 'a' uses catalog:default (unchanged) + // 'a' uses "catalog:" (default) — default catalog is unchanged expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); - // Project 'c' has no catalog deps + // 'c' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); - it('treats all catalogs as changed when old pnpm-config.json does not exist', async () => { - const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); - const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( - resolve(rootDir, 'rush.json') - ); - - // pnpm-config.json was newly created - mockGetRepoChanges.mockReturnValue( - new Map([ - [ - 'common/config/rush/pnpm-config.json', - { - mode: 'added', - newhash: 'newhash', - oldhash: '', - status: 'A' - } - ] - ]) - ); - - // Simulate file not existing in old commit - mockGetBlobContentAsync.mockImplementation(() => { - return Promise.reject(new Error('fatal: path not found')); + it('no changes when catalogs are unchanged', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // Old catalogs are identical to current + mockOldCatalogs({ + default: { react: '^18.0.0', lodash: '^4.17.21' }, + tools: { typescript: '~5.3.0' } }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); - const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); - const terminal: Terminal = new Terminal(terminalProvider); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ enableFiltering: false, @@ -732,37 +681,19 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // All catalog-using projects should be detected - expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); - expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); - // Project 'c' has no catalog deps, still not detected - expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + expect(changedProjects.size).toBe(0); }); - it('does not detect additional projects when pnpm-config.json is not changed', async () => { - const rootDir: string = resolve(__dirname, 'repoWithCatalogs'); - const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( - resolve(rootDir, 'rush.json') - ); - - // Only a source file changed, not pnpm-config.json - mockGetRepoChanges.mockReturnValue( - new Map([ - [ - 'c/src/index.ts', - { - mode: 'modified', - newhash: 'newhash', - oldhash: 'oldhash', - status: 'M' - } - ] - ]) - ); + it('all catalog-using projects marked as changed when no old catalog existed', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // Old file did not exist in git + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.reject(new Error('fatal: path not found')); + }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); - const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); - const terminal: Terminal = new Terminal(terminalProvider); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ enableFiltering: false, @@ -771,10 +702,11 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // Only project 'c' should be detected (direct file change) - expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(true); - expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); - expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'a' uses catalog:default, 'b' uses catalog:tools — both detected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + // 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); }); }); From 23161e1138969e599ca98b561f9a1e57a8fd6725 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Tue, 17 Feb 2026 15:05:15 -0500 Subject: [PATCH 5/9] cater for subspaces --- .../src/logic/ProjectChangeAnalyzer.ts | 50 ++++-- .../logic/test/ProjectChangeAnalyzer.test.ts | 152 ++++++++++++++++++ .../repoWithSubspacesCatalogs/a/package.json | 8 + .../repoWithSubspacesCatalogs/b/package.json | 8 + .../repoWithSubspacesCatalogs/c/package.json | 8 + .../common/config/rush/experiments.json | 3 + .../common/config/rush/pnpm-config.json | 4 + .../common/config/rush/subspaces.json | 5 + .../common/config/rush/version-policies.json | 1 + .../config/subspaces/default/.pnpmfile.cjs | 9 ++ .../subspaces/default/common-versions.json | 8 + .../config/subspaces/default/pnpm-config.json | 8 + .../.pnpmfile.cjs | 9 ++ .../common-versions.json | 8 + .../pnpm-config.json | 11 ++ .../repoWithSubspacesCatalogs/d/package.json | 8 + .../repoWithSubspacesCatalogs/e/package.json | 11 ++ .../repoWithSubspacesCatalogs/f/package.json | 5 + .../test/repoWithSubspacesCatalogs/rush.json | 34 ++++ 19 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/a/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/b/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/c/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/experiments.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/subspaces.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/version-policies.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/.pnpmfile.cjs create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/common-versions.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/.pnpmfile.cjs create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/common-versions.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/d/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/e/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/f/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/rush.json diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 3023329e3e..fe1a8cc84e 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -24,8 +24,7 @@ import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile'; import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile'; import { Git } from './Git'; import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier'; -import { RushConstants } from './RushConstants'; -import type { IPnpmOptionsJson } from './pnpm/PnpmOptionsConfiguration'; +import type { IPnpmOptionsJson, PnpmOptionsConfiguration } from './pnpm/PnpmOptionsConfiguration'; import { type IInputsSnapshotProjectMetadata, type IInputsSnapshot, @@ -181,18 +180,27 @@ export class ProjectChangeAnalyzer { { concurrency: 10 } ); - // Detect changes to pnpm catalog entries in pnpm-config.json - if (rushConfiguration.isPnpm && rushConfiguration.pnpmOptions.globalCatalogs) { - const pnpmConfigRelativePath: string = Path.convertToSlashes( - path.relative( - repoRoot, - path.join(rushConfiguration.commonRushConfigFolder, RushConstants.pnpmConfigFilename) - ) - ); + // Detect changes to pnpm catalog entries in pnpm-config.json per subspace + if (rushConfiguration.isPnpm) { + const catalogSubspaces: Iterable = rushConfiguration.subspacesFeatureEnabled + ? rushConfiguration.subspaces + : [rushConfiguration.defaultSubspace]; + + await Async.forEachAsync(catalogSubspaces, async (subspace: Subspace) => { + const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions(); + const currentCatalogs: Record> | undefined = + pnpmOptions?.globalCatalogs; + if (!currentCatalogs) { + return; + } + + const pnpmConfigRelativePath: string = Path.convertToSlashes( + path.relative(repoRoot, subspace.getPnpmConfigFilePath()) + ); - if (changedFiles.has(pnpmConfigRelativePath)) { - const currentCatalogs: Record> = rushConfiguration.pnpmOptions - .globalCatalogs; + if (!changedFiles.has(pnpmConfigRelativePath)) { + return; + } // Determine which catalog names have changed let changedCatalogNames: Set; @@ -230,12 +238,22 @@ export class ProjectChangeAnalyzer { } catch { // Old file didn't exist or was unparseable — treat all current catalogs as changed changedCatalogNames = new Set(Object.keys(currentCatalogs)); + if (rushConfiguration.subspacesFeatureEnabled) { + terminal.writeLine( + `"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.` + ); + } else { + terminal.writeLine( + `pnpm-config.json was created or unparseable. Assuming all projects are affected.` + ); + } } if (changedCatalogNames.size > 0) { - // Build a map of catalogName → Set + // Build a map of catalogName → Set for this subspace's projects + const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); const catalogToProjects: Map> = new Map(); - for (const project of rushConfiguration.projects) { + for (const project of subspaceProjects) { const { dependencies, devDependencies, optionalDependencies } = project.packageJson; const allDeps: Record[] = [ dependencies ?? {}, @@ -275,7 +293,7 @@ export class ProjectChangeAnalyzer { } } } - } + }); } // External dependency changes are not allowed to be filtered, so add these after filtering diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index 09e9cad8bd..be8dbd46ad 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -709,6 +709,158 @@ describe(ProjectChangeAnalyzer.name, () => { expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); }); + + describe('subspace catalog change detection', () => { + function getSubspaceCatalogRushConfiguration(): RushConfiguration { + return RushConfiguration.loadFromConfigurationFile( + resolve(__dirname, 'repoWithSubspacesCatalogs', 'rush.json') + ); + } + + it('detects change to default catalog in subspace', async () => { + const rushConfiguration: RushConfiguration = getSubspaceCatalogRushConfiguration(); + + // Only the subspace pnpm-config.json changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json', + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } + ] + ]) + ); + + // foo version bumped in the default catalog + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { default: { foo: '~1.0.0' }, tools: { typescript: '~5.3.0' } } + }) + ); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'd' uses "catalog:" (default) for foo — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); + // 'e' uses "catalog:tools" only — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'f' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); + // default subspace projects should not be affected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + + it('detects change to named catalog in subspace', async () => { + const rushConfiguration: RushConfiguration = getSubspaceCatalogRushConfiguration(); + + // Only the subspace pnpm-config.json changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json', + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } + ] + ]) + ); + + // typescript version bumped in the tools catalog + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { default: { foo: '~2.0.0' }, tools: { typescript: '~5.2.0' } } + }) + ); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'e' uses "catalog:tools" for typescript — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); + // 'd' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'f' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); + // default subspace projects should not be affected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + }); + + it('detects changes when multiple subspace pnpm-configs have catalog changes', async () => { + const rushConfiguration: RushConfiguration = getSubspaceCatalogRushConfiguration(); + + // Both the default subspace and named subspace pnpm-config.json files changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/subspaces/default/pnpm-config.json', + { mode: 'modified', newhash: 'newhash1', oldhash: 'oldhash1', status: 'M' } + ], + [ + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json', + { mode: 'modified', newhash: 'newhash2', oldhash: 'oldhash2', status: 'M' } + ] + ]) + ); + + // Return old catalogs based on which config is being read + mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => { + if (opts.blobSpec.includes('default/pnpm-config.json')) { + // react version bumped in the default subspace + return Promise.resolve(JSON.stringify({ globalCatalogs: { default: { react: '^17.0.0' } } })); + } + if (opts.blobSpec.includes('project-change-analyzer-test-subspace/pnpm-config.json')) { + // foo version bumped in the named subspace + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { default: { foo: '~1.0.0' }, tools: { typescript: '~5.3.0' } } + }) + ); + } + return Promise.resolve('{}'); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'a' uses "catalog:" (default) for react in the default subspace — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + // 'd' uses "catalog:" (default) for foo in the named subspace — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); + // 'b' has no catalog deps in default subspace + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'c' has no catalog deps in default subspace + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + // 'e' uses "catalog:tools" — tools catalog is unchanged in the named subspace + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'f' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); + }); + }); }); describe('isPackageJsonVersionOnlyChange', () => { diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/a/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/a/package.json new file mode 100644 index 0000000000..5c102c007b --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "a", + "version": "1.0.0", + "description": "Test package a", + "dependencies": { + "react": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/b/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/b/package.json new file mode 100644 index 0000000000..3a7fdf92a4 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/c/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/c/package.json new file mode 100644 index 0000000000..84d308bd6c --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/rush/experiments.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/experiments.json new file mode 100644 index 0000000000..a20c6d2438 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "exemptDecoupledDependenciesBetweenSubspaces": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 0000000000..fdb1cb3ac5 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/rush/subspaces.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/subspaces.json new file mode 100644 index 0000000000..ab4e6b3a3c --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/rush/version-policies.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/version-policies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/rush/version-policies.json @@ -0,0 +1 @@ +[] diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/.pnpmfile.cjs b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/.pnpmfile.cjs new file mode 100644 index 0000000000..ee041f83a4 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/subspaces/default/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/common-versions.json new file mode 100644 index 0000000000..9280fe7b96 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/subspaces/default/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/pnpm-config.json new file mode 100644 index 0000000000..291ec69714 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/default/pnpm-config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "globalCatalogs": { + "default": { + "react": "^18.0.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/.pnpmfile.cjs b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/.pnpmfile.cjs new file mode 100644 index 0000000000..ee041f83a4 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/common-versions.json new file mode 100644 index 0000000000..9280fe7b96 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json new file mode 100644 index 0000000000..2b6ad8bd42 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "globalCatalogs": { + "default": { + "foo": "~2.0.0" + }, + "tools": { + "typescript": "~5.3.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/d/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/d/package.json new file mode 100644 index 0000000000..bf308cf79f --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/d/package.json @@ -0,0 +1,8 @@ +{ + "name": "d", + "version": "4.1.1", + "description": "Test package d", + "dependencies": { + "foo": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/e/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/e/package.json new file mode 100644 index 0000000000..1d6b2e2646 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/e/package.json @@ -0,0 +1,11 @@ +{ + "name": "e", + "version": "10.10.0", + "description": "Test package e", + "dependencies": { + "d": "workspace:*" + }, + "devDependencies": { + "typescript": "catalog:tools" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/f/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/f/package.json new file mode 100644 index 0000000000..5d6ea8a762 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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/repoWithSubspacesCatalogs/rush.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/rush.json new file mode 100644 index 0000000000..895d07437c --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/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" + } + ] +} From 06b43b112f2ccff7e92c03a060e3ba3b5c75f2de Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Tue, 17 Feb 2026 15:31:58 -0500 Subject: [PATCH 6/9] remove redundant subspace logic --- .../src/logic/ProjectChangeAnalyzer.ts | 274 +++++++++--------- 1 file changed, 145 insertions(+), 129 deletions(-) diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index fe1a8cc84e..8d6851805e 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -180,142 +180,42 @@ export class ProjectChangeAnalyzer { { concurrency: 10 } ); - // Detect changes to pnpm catalog entries in pnpm-config.json per subspace - if (rushConfiguration.isPnpm) { - const catalogSubspaces: Iterable = rushConfiguration.subspacesFeatureEnabled - ? rushConfiguration.subspaces - : [rushConfiguration.defaultSubspace]; - - await Async.forEachAsync(catalogSubspaces, async (subspace: Subspace) => { - const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions(); - const currentCatalogs: Record> | undefined = - pnpmOptions?.globalCatalogs; - if (!currentCatalogs) { - return; - } - - const pnpmConfigRelativePath: string = Path.convertToSlashes( - path.relative(repoRoot, subspace.getPnpmConfigFilePath()) - ); - - if (!changedFiles.has(pnpmConfigRelativePath)) { - return; - } - - // Determine which catalog names have changed - let changedCatalogNames: Set; - try { - const oldPnpmConfigText: string = await this._git.getBlobContentAsync({ - blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`, - repositoryRoot: repoRoot - }); - const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText); - const oldCatalogs: Record> = oldPnpmConfig.globalCatalogs ?? {}; - - changedCatalogNames = new Set(); + // Detect per-subspace changes: catalog entries in pnpm-config.json and external dependency lockfiles + const subspaces: Iterable = rushConfiguration.subspacesFeatureEnabled + ? rushConfiguration.subspaces + : [rushConfiguration.defaultSubspace]; - // Check current catalogs for new or modified entries - for (const [catalogName, packages] of Object.entries(currentCatalogs)) { - const oldPackages: Record | undefined = oldCatalogs[catalogName]; - if (!oldPackages) { - changedCatalogNames.add(catalogName); - continue; - } - for (const [pkgName, version] of Object.entries(packages)) { - if (oldPackages[pkgName] !== version) { - changedCatalogNames.add(catalogName); - break; - } - } - } + const variantToUse: string | undefined = includeExternalDependencies + ? (variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync())) + : undefined; - // Check for catalogs that were removed - for (const catalogName of Object.keys(oldCatalogs)) { - if (!(catalogName in currentCatalogs)) { - changedCatalogNames.add(catalogName); - } - } - } catch { - // Old file didn't exist or was unparseable — treat all current catalogs as changed - changedCatalogNames = new Set(Object.keys(currentCatalogs)); - if (rushConfiguration.subspacesFeatureEnabled) { - terminal.writeLine( - `"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.` - ); - } else { - terminal.writeLine( - `pnpm-config.json was created or unparseable. Assuming all projects are affected.` - ); - } - } + await Async.forEachAsync(subspaces, async (subspace: Subspace) => { + const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); - if (changedCatalogNames.size > 0) { - // Build a map of catalogName → Set for this subspace's projects - const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); - const catalogToProjects: Map> = new Map(); - for (const project of subspaceProjects) { - const { dependencies, devDependencies, optionalDependencies } = project.packageJson; - const allDeps: Record[] = [ - dependencies ?? {}, - devDependencies ?? {}, - optionalDependencies ?? {} - ]; - - for (const deps of allDeps) { - for (const [depName, depVersion] of Object.entries(deps)) { - const specifier: DependencySpecifier = DependencySpecifier.parseWithCache( - depName, - depVersion - ); - if (specifier.specifierType === DependencySpecifierType.Catalog) { - // versionSpecifier holds the catalog name (empty string for "catalog:") - const catalogName: string = specifier.versionSpecifier || 'default'; - let projectSet: Set | undefined = - catalogToProjects.get(catalogName); - if (!projectSet) { - projectSet = new Set(); - catalogToProjects.set(catalogName, projectSet); - } - projectSet.add(project); - } - } - } - } - - // Mark projects using changed catalogs as changed - for (const catalogName of changedCatalogNames) { - const affectedProjects: Set | undefined = - catalogToProjects.get(catalogName); - if (affectedProjects) { - for (const project of affectedProjects) { - changedProjects.add(project); - } - } - } - } - }); - } - - // External dependency changes are not allowed to be filtered, so add these after filtering - if (includeExternalDependencies) { - // 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]; + // Detect changes to pnpm catalog entries in pnpm-config.json + if (rushConfiguration.isPnpm) { + await this._detectCatalogChangesAsync( + subspace, + rushConfiguration, + changedFiles, + mergeCommit, + repoRoot, + terminal, + changedProjects + ); + } - const variantToUse: string | undefined = - variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync()); + // External dependency changes are not allowed to be filtered, so add these after filtering + if (includeExternalDependencies) { + // Even though changing the installed version of a nested dependency merits a change file, + // ignore lockfile changes for `rush change` for the moment - await Async.forEachAsync(subspaces, async (subspace: Subspace) => { const fullShrinkwrapPath: string = subspace.getCommittedShrinkwrapFilePath(variantToUse); const relativeShrinkwrapFilePath: string = Path.convertToSlashes( path.relative(repoRoot, fullShrinkwrapPath) ); const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath); - const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); if (shrinkwrapStatus) { if (shrinkwrapStatus.status !== 'M') { @@ -333,7 +233,7 @@ export class ProjectChangeAnalyzer { } if (rushConfiguration.isPnpm) { - const subspaceHasNoProjects: boolean = subspace.getProjects().length === 0; + const subspaceHasNoProjects: boolean = subspaceProjects.length === 0; const currentShrinkwrap: PnpmShrinkwrapFile | undefined = PnpmShrinkwrapFile.loadFromFile( fullShrinkwrapPath, { subspaceHasNoProjects } @@ -371,12 +271,12 @@ export class ProjectChangeAnalyzer { `Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.` ); } - subspace.getProjects().forEach((project) => changedProjects.add(project)); + subspaceProjects.forEach((project) => changedProjects.add(project)); return; } } - }); - } + } + }); // Sort the set by projectRelativeFolder to avoid race conditions in the results const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects); @@ -609,6 +509,122 @@ export class ProjectChangeAnalyzer { return ignoreMatcher; } } + + /** + * Detects changes to pnpm catalog entries in a subspace's pnpm-config.json and marks + * affected projects as changed. + */ + private async _detectCatalogChangesAsync( + subspace: Subspace, + rushConfiguration: RushConfiguration, + changedFiles: Map, + mergeCommit: string, + repoRoot: string, + terminal: ITerminal, + changedProjects: Set + ): Promise { + const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions(); + const currentCatalogs: Record> | undefined = pnpmOptions?.globalCatalogs; + if (!currentCatalogs) { + return; + } + + const pnpmConfigRelativePath: string = Path.convertToSlashes( + path.relative(repoRoot, subspace.getPnpmConfigFilePath()) + ); + + if (!changedFiles.has(pnpmConfigRelativePath)) { + return; + } + + // Determine which catalog names have changed + let changedCatalogNames: Set; + try { + const oldPnpmConfigText: string = await this._git.getBlobContentAsync({ + blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`, + repositoryRoot: repoRoot + }); + const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText); + const oldCatalogs: Record> = oldPnpmConfig.globalCatalogs ?? {}; + + changedCatalogNames = new Set(); + + // Check current catalogs for new or modified entries + for (const [catalogName, packages] of Object.entries(currentCatalogs)) { + const oldPackages: Record | undefined = oldCatalogs[catalogName]; + if (!oldPackages) { + changedCatalogNames.add(catalogName); + continue; + } + for (const [pkgName, version] of Object.entries(packages)) { + if (oldPackages[pkgName] !== version) { + changedCatalogNames.add(catalogName); + break; + } + } + } + + // Check for catalogs that were removed + for (const catalogName of Object.keys(oldCatalogs)) { + if (!(catalogName in currentCatalogs)) { + changedCatalogNames.add(catalogName); + } + } + } catch { + // Old file didn't exist or was unparseable — treat all current catalogs as changed + changedCatalogNames = new Set(Object.keys(currentCatalogs)); + if (rushConfiguration.subspacesFeatureEnabled) { + terminal.writeLine( + `"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.` + ); + } else { + terminal.writeLine( + `pnpm-config.json was created or unparseable. Assuming all projects are affected.` + ); + } + } + + if (changedCatalogNames.size > 0) { + // Build a map of catalogName → Set for this subspace's projects + const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); + const catalogToProjects: Map> = new Map(); + for (const project of subspaceProjects) { + const { dependencies, devDependencies, optionalDependencies } = project.packageJson; + const allDeps: Record[] = [ + dependencies ?? {}, + devDependencies ?? {}, + optionalDependencies ?? {} + ]; + + for (const deps of allDeps) { + for (const [depName, depVersion] of Object.entries(deps)) { + const specifier: DependencySpecifier = DependencySpecifier.parseWithCache(depName, depVersion); + if (specifier.specifierType === DependencySpecifierType.Catalog) { + // versionSpecifier holds the catalog name (empty string for "catalog:") + const catalogName: string = specifier.versionSpecifier || 'default'; + let projectSet: Set | undefined = catalogToProjects.get(catalogName); + if (!projectSet) { + projectSet = new Set(); + catalogToProjects.set(catalogName, projectSet); + } + projectSet.add(project); + } + } + } + } + + // Mark projects using changed catalogs as changed + for (const catalogName of changedCatalogNames) { + const affectedProjects: Set | undefined = + catalogToProjects.get(catalogName); + if (affectedProjects) { + for (const project of affectedProjects) { + changedProjects.add(project); + } + } + } + } + } } /** From 04bd1c806e79834485ac2ee4f8db4e0b24e414a7 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Wed, 18 Feb 2026 13:27:53 -0500 Subject: [PATCH 7/9] consider namespaced catalog package version changes --- .../src/logic/ProjectChangeAnalyzer.ts | 67 +++-- .../logic/test/ProjectChangeAnalyzer.test.ts | 279 +++++++++++++++++- .../common/config/rush/pnpm-config.json | 3 +- .../test/repoWithCatalogs/d/package.json | 8 + .../test/repoWithCatalogs/e/package.json | 8 + .../src/logic/test/repoWithCatalogs/rush.json | 8 + .../pnpm-config.json | 6 +- .../repoWithSubspacesCatalogs/g/package.json | 8 + .../repoWithSubspacesCatalogs/h/package.json | 8 + .../test/repoWithSubspacesCatalogs/rush.json | 10 + 10 files changed, 357 insertions(+), 48 deletions(-) create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/d/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithCatalogs/e/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/g/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/h/package.json diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 8d6851805e..8d07d2e8df 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -537,8 +537,9 @@ export class ProjectChangeAnalyzer { return; } - // Determine which catalog names have changed - let changedCatalogNames: Set; + // Determine which specific packages changed within each catalog namespace + // Maps catalogNamespace (e.g. "default", "react17") → Set of changed package names + let changedCatalogPackages: Map>; try { const oldPnpmConfigText: string = await this._git.getBlobContentAsync({ blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`, @@ -547,32 +548,45 @@ export class ProjectChangeAnalyzer { const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText); const oldCatalogs: Record> = oldPnpmConfig.globalCatalogs ?? {}; - changedCatalogNames = new Set(); + changedCatalogPackages = new Map>(); - // Check current catalogs for new or modified entries + // Check current catalogs for new or modified package entries for (const [catalogName, packages] of Object.entries(currentCatalogs)) { const oldPackages: Record | undefined = oldCatalogs[catalogName]; if (!oldPackages) { - changedCatalogNames.add(catalogName); + // Entire catalog is new — all packages in it are changed + changedCatalogPackages.set(catalogName, new Set(Object.keys(packages))); continue; } + const changedPkgs: Set = new Set(); for (const [pkgName, version] of Object.entries(packages)) { if (oldPackages[pkgName] !== version) { - changedCatalogNames.add(catalogName); - break; + changedPkgs.add(pkgName); + } + } + // Check for packages that were removed from this catalog + for (const pkgName of Object.keys(oldPackages)) { + if (!(pkgName in packages)) { + changedPkgs.add(pkgName); } } + if (changedPkgs.size > 0) { + changedCatalogPackages.set(catalogName, changedPkgs); + } } - // Check for catalogs that were removed - for (const catalogName of Object.keys(oldCatalogs)) { + // Check for catalogs that were entirely removed + for (const [catalogName, oldPackages] of Object.entries(oldCatalogs)) { if (!(catalogName in currentCatalogs)) { - changedCatalogNames.add(catalogName); + changedCatalogPackages.set(catalogName, new Set(Object.keys(oldPackages))); } } } catch { - // Old file didn't exist or was unparseable — treat all current catalogs as changed - changedCatalogNames = new Set(Object.keys(currentCatalogs)); + // Old file didn't exist or was unparseable — treat all packages in all current catalogs as changed + changedCatalogPackages = new Map>(); + for (const [catalogName, packages] of Object.entries(currentCatalogs)) { + changedCatalogPackages.set(catalogName, new Set(Object.keys(packages))); + } if (rushConfiguration.subspacesFeatureEnabled) { terminal.writeLine( `"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.` @@ -584,10 +598,9 @@ export class ProjectChangeAnalyzer { } } - if (changedCatalogNames.size > 0) { - // Build a map of catalogName → Set for this subspace's projects + if (changedCatalogPackages.size > 0) { + // Check each project in the subspace to see if it depends on a changed catalog package const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); - const catalogToProjects: Map> = new Map(); for (const project of subspaceProjects) { const { dependencies, devDependencies, optionalDependencies } = project.packageJson; const allDeps: Record[] = [ @@ -596,31 +609,27 @@ export class ProjectChangeAnalyzer { optionalDependencies ?? {} ]; + let isAffected: boolean = false; for (const deps of allDeps) { + if (isAffected) { + break; + } for (const [depName, depVersion] of Object.entries(deps)) { const specifier: DependencySpecifier = DependencySpecifier.parseWithCache(depName, depVersion); if (specifier.specifierType === DependencySpecifierType.Catalog) { // versionSpecifier holds the catalog name (empty string for "catalog:") const catalogName: string = specifier.versionSpecifier || 'default'; - let projectSet: Set | undefined = catalogToProjects.get(catalogName); - if (!projectSet) { - projectSet = new Set(); - catalogToProjects.set(catalogName, projectSet); + const changedPkgs: Set | undefined = changedCatalogPackages.get(catalogName); + if (changedPkgs?.has(depName)) { + isAffected = true; + break; } - projectSet.add(project); } } } - } - // Mark projects using changed catalogs as changed - for (const catalogName of changedCatalogNames) { - const affectedProjects: Set | undefined = - catalogToProjects.get(catalogName); - if (affectedProjects) { - for (const project of affectedProjects) { - changedProjects.add(project); - } + if (isAffected) { + changedProjects.add(project); } } } diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index be8dbd46ad..b03269fc01 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -614,7 +614,7 @@ describe(ProjectChangeAnalyzer.name, () => { // react version bumped in the default catalog mockOldCatalogs({ default: { react: '^17.0.0', lodash: '^4.17.21' }, - tools: { typescript: '~5.3.0' } + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); @@ -627,10 +627,14 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // 'a' uses "catalog:" (default) for react and lodash + // 'a' uses "catalog:" (default) for react (changed) and lodash expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + // 'd' uses "catalog:" (default) for lodash only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); // 'b' uses "catalog:tools" only — tools catalog is unchanged expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'e' uses "catalog:tools" for eslint only — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); // 'c' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); @@ -641,7 +645,7 @@ describe(ProjectChangeAnalyzer.name, () => { // typescript version bumped in the tools catalog mockOldCatalogs({ default: { react: '^18.0.0', lodash: '^4.17.21' }, - tools: { typescript: '~5.2.0' } + tools: { typescript: '~5.2.0', eslint: '^8.50.0' } }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); @@ -654,10 +658,14 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // 'b' uses "catalog:tools" for typescript + // 'b' uses "catalog:tools" for typescript (changed) expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + // 'e' uses "catalog:tools" for eslint only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); // 'a' uses "catalog:" (default) — default catalog is unchanged expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + // 'd' uses "catalog:" (default) for lodash — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); // 'c' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); @@ -668,7 +676,7 @@ describe(ProjectChangeAnalyzer.name, () => { // Old catalogs are identical to current mockOldCatalogs({ default: { react: '^18.0.0', lodash: '^4.17.21' }, - tools: { typescript: '~5.3.0' } + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } }); const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); @@ -684,6 +692,130 @@ describe(ProjectChangeAnalyzer.name, () => { expect(changedProjects.size).toBe(0); }); + it('only marks projects whose specific catalog package changed, not all projects in the same catalog namespace', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // Only react changed in the default catalog; lodash is unchanged + mockOldCatalogs({ + default: { react: '^17.0.0', lodash: '^4.17.21' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'a' uses "catalog:" for react (changed) and lodash (unchanged) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + // 'd' uses "catalog:" for lodash only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'b' uses "catalog:tools" for typescript — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'e' uses "catalog:tools" for eslint — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + + it('marks project when its specific package version changed in catalog, even if other packages in same catalog are unchanged', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // Only lodash changed in the default catalog; react is unchanged + mockOldCatalogs({ + default: { react: '^18.0.0', lodash: '^4.16.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'a' uses "catalog:" for react (unchanged) and lodash (changed) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + // 'd' uses "catalog:" for lodash (changed) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); + // 'b' uses "catalog:tools" for typescript — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'e' uses "catalog:tools" for eslint — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + + it('only marks projects whose specific named catalog package changed', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // Only typescript changed in the tools catalog; eslint is unchanged + mockOldCatalogs({ + default: { react: '^18.0.0', lodash: '^4.17.21' }, + tools: { typescript: '~5.2.0', eslint: '^8.50.0' } + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'b' uses "catalog:tools" for typescript (changed) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + // 'e' uses "catalog:tools" for eslint only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'a' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + // 'd' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + + it('marks project when its specific named catalog package changed', async () => { + const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); + mockPnpmConfigChanged(); + // Only eslint changed in the tools catalog; typescript is unchanged + mockOldCatalogs({ + default: { react: '^18.0.0', lodash: '^4.17.21' }, + tools: { typescript: '~5.3.0', eslint: '^8.40.0' } + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'e' uses "catalog:tools" for eslint (changed) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); + // 'b' uses "catalog:tools" for typescript only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); + // 'a' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + // 'd' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'c' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); + }); + it('all catalog-using projects marked as changed when no old catalog existed', async () => { const rushConfiguration: RushConfiguration = getCatalogRushConfiguration(); mockPnpmConfigChanged(); @@ -702,9 +834,11 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // 'a' uses catalog:default, 'b' uses catalog:tools — both detected + // All catalog-using projects detected: 'a' (default: react, lodash), 'b' (tools: typescript), 'd' (default: lodash), 'e' (tools: eslint) expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); // 'c' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); }); @@ -734,7 +868,10 @@ describe(ProjectChangeAnalyzer.name, () => { mockGetBlobContentAsync.mockImplementation(() => { return Promise.resolve( JSON.stringify({ - globalCatalogs: { default: { foo: '~1.0.0' }, tools: { typescript: '~5.3.0' } } + globalCatalogs: { + default: { foo: '~1.0.0', bar: '^3.0.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + } }) ); }); @@ -749,10 +886,14 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // 'd' uses "catalog:" (default) for foo — should be detected + // 'd' uses "catalog:" (default) for foo (changed) — should be detected expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); - // 'e' uses "catalog:tools" only — tools catalog is unchanged + // 'g' uses "catalog:" (default) for bar only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('g')!)).toBe(false); + // 'e' uses "catalog:tools" for typescript — tools catalog is unchanged expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'h' uses "catalog:tools" for eslint — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('h')!)).toBe(false); // 'f' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); // default subspace projects should not be affected @@ -778,7 +919,10 @@ describe(ProjectChangeAnalyzer.name, () => { mockGetBlobContentAsync.mockImplementation(() => { return Promise.resolve( JSON.stringify({ - globalCatalogs: { default: { foo: '~2.0.0' }, tools: { typescript: '~5.2.0' } } + globalCatalogs: { + default: { foo: '~2.0.0', bar: '^3.0.0' }, + tools: { typescript: '~5.2.0', eslint: '^8.50.0' } + } }) ); }); @@ -793,16 +937,112 @@ describe(ProjectChangeAnalyzer.name, () => { terminal }); - // 'e' uses "catalog:tools" for typescript — should be detected + // 'e' uses "catalog:tools" for typescript (changed) — should be detected expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); - // 'd' uses "catalog:" (default) — default catalog is unchanged + // 'h' uses "catalog:tools" for eslint only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('h')!)).toBe(false); + // 'd' uses "catalog:" (default) for foo — default catalog is unchanged expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'g' uses "catalog:" (default) for bar — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('g')!)).toBe(false); // 'f' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); // default subspace projects should not be affected expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(false); }); + it('only marks subspace projects whose specific default catalog package changed', async () => { + const rushConfiguration: RushConfiguration = getSubspaceCatalogRushConfiguration(); + + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json', + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } + ] + ]) + ); + + // Only bar changed in the default catalog; foo is unchanged + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { + default: { foo: '~2.0.0', bar: '^2.0.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + } + }) + ); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'g' uses "catalog:" for bar (changed) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('g')!)).toBe(true); + // 'd' uses "catalog:" for foo only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'e' uses "catalog:tools" — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'h' uses "catalog:tools" — tools catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('h')!)).toBe(false); + // 'f' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); + }); + + it('only marks subspace projects whose specific named catalog package changed', async () => { + const rushConfiguration: RushConfiguration = getSubspaceCatalogRushConfiguration(); + + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json', + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } + ] + ]) + ); + + // Only eslint changed in the tools catalog; typescript is unchanged + mockGetBlobContentAsync.mockImplementation(() => { + return Promise.resolve( + JSON.stringify({ + globalCatalogs: { + default: { foo: '~2.0.0', bar: '^3.0.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.40.0' } + } + }) + ); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(true)); + + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + + // 'h' uses "catalog:tools" for eslint (changed) — should be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('h')!)).toBe(true); + // 'e' uses "catalog:tools" for typescript only (unchanged) — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'd' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + // 'g' uses "catalog:" (default) — default catalog is unchanged + expect(changedProjects.has(rushConfiguration.getProjectByName('g')!)).toBe(false); + // 'f' has no catalog deps + expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); + }); + it('detects changes when multiple subspace pnpm-configs have catalog changes', async () => { const rushConfiguration: RushConfiguration = getSubspaceCatalogRushConfiguration(); @@ -827,10 +1067,13 @@ describe(ProjectChangeAnalyzer.name, () => { return Promise.resolve(JSON.stringify({ globalCatalogs: { default: { react: '^17.0.0' } } })); } if (opts.blobSpec.includes('project-change-analyzer-test-subspace/pnpm-config.json')) { - // foo version bumped in the named subspace + // foo version bumped in the named subspace; bar, tools unchanged return Promise.resolve( JSON.stringify({ - globalCatalogs: { default: { foo: '~1.0.0' }, tools: { typescript: '~5.3.0' } } + globalCatalogs: { + default: { foo: '~1.0.0', bar: '^3.0.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + } }) ); } @@ -849,14 +1092,18 @@ describe(ProjectChangeAnalyzer.name, () => { // 'a' uses "catalog:" (default) for react in the default subspace — should be detected expect(changedProjects.has(rushConfiguration.getProjectByName('a')!)).toBe(true); - // 'd' uses "catalog:" (default) for foo in the named subspace — should be detected + // 'd' uses "catalog:" (default) for foo (changed) in the named subspace — should be detected expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(true); + // 'g' uses "catalog:" (default) for bar (unchanged) in the named subspace — should NOT be detected + expect(changedProjects.has(rushConfiguration.getProjectByName('g')!)).toBe(false); // 'b' has no catalog deps in default subspace expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(false); // 'c' has no catalog deps in default subspace expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(false); - // 'e' uses "catalog:tools" — tools catalog is unchanged in the named subspace + // 'e' uses "catalog:tools" for typescript — tools catalog is unchanged in the named subspace expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(false); + // 'h' uses "catalog:tools" for eslint — tools catalog is unchanged in the named subspace + expect(changedProjects.has(rushConfiguration.getProjectByName('h')!)).toBe(false); // 'f' has no catalog deps expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); }); diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json index a8bee68efa..32448ef022 100644 --- a/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/common/config/rush/pnpm-config.json @@ -6,7 +6,8 @@ "lodash": "^4.17.21" }, "tools": { - "typescript": "~5.3.0" + "typescript": "~5.3.0", + "eslint": "^8.50.0" } } } diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/d/package.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/d/package.json new file mode 100644 index 0000000000..31e197b4f8 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/d/package.json @@ -0,0 +1,8 @@ +{ + "name": "d", + "version": "1.0.0", + "description": "Project D uses only lodash from default catalog", + "dependencies": { + "lodash": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/e/package.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/e/package.json new file mode 100644 index 0000000000..8992f86514 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/e/package.json @@ -0,0 +1,8 @@ +{ + "name": "e", + "version": "1.0.0", + "description": "Project E uses only eslint from tools catalog", + "devDependencies": { + "eslint": "catalog:tools" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json b/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json index 01cf3a4cb5..848cf846c1 100644 --- a/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json +++ b/libraries/rush-lib/src/logic/test/repoWithCatalogs/rush.json @@ -14,6 +14,14 @@ { "packageName": "c", "projectFolder": "c" + }, + { + "packageName": "d", + "projectFolder": "d" + }, + { + "packageName": "e", + "projectFolder": "e" } ] } diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json index 2b6ad8bd42..cc0a0ac626 100644 --- a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/common/config/subspaces/project-change-analyzer-test-subspace/pnpm-config.json @@ -2,10 +2,12 @@ "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", "globalCatalogs": { "default": { - "foo": "~2.0.0" + "foo": "~2.0.0", + "bar": "^3.0.0" }, "tools": { - "typescript": "~5.3.0" + "typescript": "~5.3.0", + "eslint": "^8.50.0" } } } diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/g/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/g/package.json new file mode 100644 index 0000000000..42bbb7abbd --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/g/package.json @@ -0,0 +1,8 @@ +{ + "name": "g", + "version": "1.0.0", + "description": "Test package g — uses only bar from default catalog", + "dependencies": { + "bar": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/h/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/h/package.json new file mode 100644 index 0000000000..d6cc9ac86d --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/h/package.json @@ -0,0 +1,8 @@ +{ + "name": "h", + "version": "1.0.0", + "description": "Test package h — uses only eslint from tools catalog", + "devDependencies": { + "eslint": "catalog:tools" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/rush.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/rush.json index 895d07437c..4714895adf 100644 --- a/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/rush.json +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCatalogs/rush.json @@ -29,6 +29,16 @@ "packageName": "f", "projectFolder": "f", "subspaceName": "project-change-analyzer-test-subspace" + }, + { + "packageName": "g", + "projectFolder": "g", + "subspaceName": "project-change-analyzer-test-subspace" + }, + { + "packageName": "h", + "projectFolder": "h", + "subspaceName": "project-change-analyzer-test-subspace" } ] } From 903a12c7c70700051a558227418a4e02c9639860 Mon Sep 17 00:00:00 2001 From: CarltonHowell <54380672+CarltonHowell@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:36:25 -0500 Subject: [PATCH 8/9] Update common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json Co-authored-by: Ian Clanton-Thuon --- .../rush/rush-change-catalog-entries_2026-02-17-18-48.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json b/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json index 8f2aed8eff..19c8142e89 100644 --- a/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json +++ b/common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json @@ -2,8 +2,8 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "add pnpm global catalog detection to rush change", - "type": "patch" + "comment": "Add support for pnpm global catalog detection to `rush change`. Now, when a dependencyis changed in the pnpm global catalog, changelogs will be required for affected published packages.", + "type": "none" } ], "packageName": "@microsoft/rush" From f77364c6d01872b5f8f7e38738221cb2ea072514 Mon Sep 17 00:00:00 2001 From: Carlton Howell Date: Fri, 20 Feb 2026 14:37:15 -0500 Subject: [PATCH 9/9] narrow try catch and account for peerDeps --- .../src/logic/ProjectChangeAnalyzer.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 8d07d2e8df..a90db706a9 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -524,10 +524,8 @@ export class ProjectChangeAnalyzer { changedProjects: Set ): Promise { const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions(); - const currentCatalogs: Record> | undefined = pnpmOptions?.globalCatalogs; - if (!currentCatalogs) { - return; - } + // Default to an empty object if no global catalogs are configured, handle case of globalCatalogs being deleted + const currentCatalogs: Record> = pnpmOptions?.globalCatalogs ?? {}; const pnpmConfigRelativePath: string = Path.convertToSlashes( path.relative(repoRoot, subspace.getPnpmConfigFilePath()) @@ -539,17 +537,35 @@ export class ProjectChangeAnalyzer { // Determine which specific packages changed within each catalog namespace // Maps catalogNamespace (e.g. "default", "react17") → Set of changed package names - let changedCatalogPackages: Map>; + let oldCatalogs: Record> | undefined; try { const oldPnpmConfigText: string = await this._git.getBlobContentAsync({ blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`, repositoryRoot: repoRoot }); const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText); - const oldCatalogs: Record> = oldPnpmConfig.globalCatalogs ?? {}; + oldCatalogs = oldPnpmConfig.globalCatalogs ?? {}; + } catch { + // Old file didn't exist or was unparseable — treat all packages in all current catalogs as changed + if (rushConfiguration.subspacesFeatureEnabled) { + terminal.writeLine( + `"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.` + ); + } else { + terminal.writeLine( + `pnpm-config.json was created or unparseable. Assuming all projects are affected.` + ); + } + } - changedCatalogPackages = new Map>(); + const changedCatalogPackages: Map> = new Map>(); + if (oldCatalogs === undefined) { + // Could not load old catalogs — treat all packages in all current catalogs as changed + for (const [catalogName, packages] of Object.entries(currentCatalogs)) { + changedCatalogPackages.set(catalogName, new Set(Object.keys(packages))); + } + } else { // Check current catalogs for new or modified package entries for (const [catalogName, packages] of Object.entries(currentCatalogs)) { const oldPackages: Record | undefined = oldCatalogs[catalogName]; @@ -581,32 +597,18 @@ export class ProjectChangeAnalyzer { changedCatalogPackages.set(catalogName, new Set(Object.keys(oldPackages))); } } - } catch { - // Old file didn't exist or was unparseable — treat all packages in all current catalogs as changed - changedCatalogPackages = new Map>(); - for (const [catalogName, packages] of Object.entries(currentCatalogs)) { - changedCatalogPackages.set(catalogName, new Set(Object.keys(packages))); - } - if (rushConfiguration.subspacesFeatureEnabled) { - terminal.writeLine( - `"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.` - ); - } else { - terminal.writeLine( - `pnpm-config.json was created or unparseable. Assuming all projects are affected.` - ); - } } if (changedCatalogPackages.size > 0) { // Check each project in the subspace to see if it depends on a changed catalog package const subspaceProjects: RushConfigurationProject[] = subspace.getProjects(); for (const project of subspaceProjects) { - const { dependencies, devDependencies, optionalDependencies } = project.packageJson; + const { dependencies, devDependencies, optionalDependencies, peerDependencies } = project.packageJson; const allDeps: Record[] = [ dependencies ?? {}, devDependencies ?? {}, - optionalDependencies ?? {} + optionalDependencies ?? {}, + peerDependencies ?? {} ]; let isAffected: boolean = false;