From ca45b2774966b9e5e05e8e9c736fe0b7b1d5b286 Mon Sep 17 00:00:00 2001 From: Rushbot Date: Fri, 4 Sep 2026 15:15:19 -0700 Subject: [PATCH] fix(rush): exempt generated range rewrites Recognize dependency ranges rewritten by rush version --bump only when the local dependency was bumped in the same diff. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...-dependency-rewrites_2026-09-04-15-00.json | 10 ++ .../src/logic/ProjectChangeAnalyzer.ts | 156 +++++++++++++++--- .../logic/test/ProjectChangeAnalyzer.test.ts | 141 ++++++++++++++++ 3 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 common/changes/@microsoft/rush/fix-exclude-version-dependency-rewrites_2026-09-04-15-00.json diff --git a/common/changes/@microsoft/rush/fix-exclude-version-dependency-rewrites_2026-09-04-15-00.json b/common/changes/@microsoft/rush/fix-exclude-version-dependency-rewrites_2026-09-04-15-00.json new file mode 100644 index 0000000000..51e183c90f --- /dev/null +++ b/common/changes/@microsoft/rush/fix-exclude-version-dependency-rewrites_2026-09-04-15-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Allow rush change --verify to exclude local dependency range rewrites generated by rush version --bump.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 3dc0fc6ed2..e0978fb7a8 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -6,7 +6,17 @@ import * as path from 'node:path'; import ignore, { type Ignore } from 'ignore'; import type { IReadonlyLookupByPath, LookupByPath, IPrefixMatch } from '@rushstack/lookup-by-path'; -import { Path, FileSystem, Async, AlreadyReportedError, Sort, JsonFile } from '@rushstack/node-core-library'; +import { + Path, + FileSystem, + Async, + AlreadyReportedError, + Sort, + JsonFile, + FileConstants, + Objects, + type IPackageJson +} from '@rushstack/node-core-library'; import { getRepoChanges, getRepoRoot, @@ -24,6 +34,7 @@ import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile'; import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile'; import { Git } from './Git'; import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier'; +import { PublishUtilities } from './PublishUtilities'; import type { IPnpmOptionsJson, PnpmOptionsConfiguration } from './pnpm/PnpmOptionsConfiguration'; import { type IInputsSnapshotProjectMetadata, @@ -55,7 +66,7 @@ export interface IGetChangedProjectsOptions { /** * If set to `true`, excludes projects where the only changes are: - * - A version-only change to `package.json` (only the "version" field differs) + * - A generated version bump in `package.json`, including generated local dependency range rewrites * - Changes to `CHANGELOG.md` and/or `CHANGELOG.json` files * * This prevents `rush version --bump` from triggering `rush change --verify` to request change files @@ -123,6 +134,9 @@ export class ProjectChangeAnalyzer { > = this.getChangesByProject(lookup, changedFiles); const changedProjects: Set = new Set(); + const changedProjectVersions: ReadonlyMap = excludeVersionOnlyChanges + ? await getChangedProjectVersionsAsync(rushConfiguration, changedFiles, repoRoot, this._git) + : new Map(); await Async.forEachAsync( changesByProject, @@ -165,7 +179,9 @@ export class ProjectChangeAnalyzer { const isVersionOnlyChange: boolean = await isVersionOnlyChangeAsync( diffStatus, repoRoot, - this._git + this._git, + rushConfiguration.projectsByName, + changedProjectVersions ); if (isVersionOnlyChange) { continue; // Skip version-only package.json changes @@ -638,7 +654,9 @@ export class ProjectChangeAnalyzer { async function isVersionOnlyChangeAsync( diffStatus: IFileDiffStatus, repoRoot: string, - git: Git + git: Git, + rushProjectsByName: ReadonlyMap, + changedProjectVersions: ReadonlyMap ): Promise { try { // Only check modified files, not additions or deletions @@ -658,13 +676,69 @@ async function isVersionOnlyChangeAsync( }) ]); - return isPackageJsonVersionOnlyChange(oldPackageJsonContent, currentPackageJsonContent); + return isPackageJsonVersionOnlyChange( + oldPackageJsonContent, + currentPackageJsonContent, + rushProjectsByName, + changedProjectVersions + ); } catch (error) { // If we can't read the file or parse it, assume it's not a version-only change return false; } } +async function getChangedProjectVersionsAsync( + rushConfiguration: RushConfiguration, + changedFiles: ReadonlyMap, + repoRoot: string, + git: Git +): Promise> { + const changedProjectVersions: Map = new Map(); + + await Async.forEachAsync( + rushConfiguration.projects, + async (project: RushConfigurationProject) => { + const packageJsonPath: string = Path.convertToSlashes( + path.relative(repoRoot, path.join(project.projectFolder, FileConstants.PackageJson)) + ); + const diffStatus: IFileDiffStatus | undefined = changedFiles.get(packageJsonPath); + if (diffStatus?.status !== 'M') { + return; + } + + try { + const [oldPackageJsonContent, newPackageJsonContent] = await Promise.all([ + git.getBlobContentAsync({ + blobSpec: diffStatus.oldhash, + repositoryRoot: repoRoot + }), + git.getBlobContentAsync({ + blobSpec: diffStatus.newhash, + repositoryRoot: repoRoot + }) + ]); + const oldPackageJson: Partial = JSON.parse(oldPackageJsonContent); + const newPackageJson: Partial = JSON.parse(newPackageJsonContent); + if ( + oldPackageJson.name === project.packageName && + newPackageJson.name === project.packageName && + oldPackageJson.version && + newPackageJson.version && + oldPackageJson.version !== newPackageJson.version + ) { + changedProjectVersions.set(project.packageName, newPackageJson.version); + } + } catch (error) { + // A malformed or unreadable package.json cannot qualify for this exemption. + } + }, + { concurrency: 10 } + ); + + return changedProjectVersions; +} + interface IAdditionalGlob { project: RushConfigurationProject; operationName: string; @@ -739,31 +813,75 @@ async function getAdditionalFilesFromRushProjectConfigurationAsync( } /** - * Compares two package.json file contents and determines if the only difference is the "version" field. + * Compares two package.json file contents and determines if the changes could have been generated by + * `rush version --bump`: a version change and, optionally, corresponding local dependency range rewrites. * @param oldPackageJsonContent - The old package.json content as a string * @param newPackageJsonContent - The new package.json content as a string - * @returns true if the only difference is the version field, false otherwise + * @returns true if all differences are generated version bump changes, false otherwise */ export function isPackageJsonVersionOnlyChange( oldPackageJsonContent: string, - newPackageJsonContent: string + newPackageJsonContent: string, + rushProjectsByName: ReadonlyMap = new Map(), + changedProjectVersions: ReadonlyMap = new Map() ): boolean { try { - // Parse both versions - use specific type since we only care about version field - const oldPackageJson: { version?: string } = JSON.parse(oldPackageJsonContent); - const newPackageJson: { version?: string } = JSON.parse(newPackageJsonContent); - - // Ensure both have a version field - if (!oldPackageJson.version || !newPackageJson.version) { + const oldPackageJson: Partial = JSON.parse(oldPackageJsonContent); + const newPackageJson: Partial = JSON.parse(newPackageJsonContent); + + if ( + !oldPackageJson.version || + !newPackageJson.version || + oldPackageJson.version === newPackageJson.version + ) { return false; } - // Remove the version field from both (no need to clone, these are fresh objects from JSON.parse) - oldPackageJson.version = undefined; - newPackageJson.version = undefined; + const dependencySections: ReadonlyArray<'dependencies' | 'devDependencies' | 'peerDependencies'> = [ + 'dependencies', + 'devDependencies', + 'peerDependencies' + ]; + + for (const dependencySection of dependencySections) { + const oldDependencies: Record | undefined = oldPackageJson[dependencySection]; + const newDependencies: Record | undefined = newPackageJson[dependencySection]; + if (!oldDependencies && !newDependencies) { + continue; + } + if ( + !oldDependencies || + !newDependencies || + !Objects.areDeepEqual(Object.keys(oldDependencies).sort(), Object.keys(newDependencies).sort()) + ) { + return false; + } + + for (const dependencyName of Object.keys(oldDependencies)) { + const oldDependencyVersion: string = oldDependencies[dependencyName]; + const newDependencyVersion: string = newDependencies[dependencyName]; + if (oldDependencyVersion === newDependencyVersion) { + continue; + } + + const newLocalVersion: string | undefined = changedProjectVersions.get(dependencyName); + if ( + !newLocalVersion || + !rushProjectsByName.has(dependencyName) || + newDependencyVersion !== + PublishUtilities.getNewDependencyVersion(oldDependencies, dependencyName, newLocalVersion) + ) { + return false; + } + } + + delete oldPackageJson[dependencySection]; + delete newPackageJson[dependencySection]; + } - // Compare the objects without the version field - return JSON.stringify(oldPackageJson) === JSON.stringify(newPackageJson); + delete oldPackageJson.version; + delete newPackageJson.version; + return Objects.areDeepEqual(oldPackageJson, newPackageJson); } catch (error) { // If we can't parse the JSON, assume it's not a version-only change return false; diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index bbc8274c26..80117e8eeb 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -305,6 +305,47 @@ describe(ProjectChangeAnalyzer.name, () => { expect(changedProjectsWithExclude.has(rushConfiguration.getProjectByName('a')!)).toBe(false); }); + it('excludeVersionOnlyChanges excludes generated local dependency rewrites', async () => { + const rootDir: string = resolve(__dirname, 'repo'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + const blobs: Record = { + 'old-a': { name: 'a', version: '1.0.0' }, + 'new-a': { name: 'a', version: '1.1.0' }, + 'old-b': { + name: 'b', + version: '2.0.0', + peerDependencies: { a: '^1.0.0' } + }, + 'new-b': { + name: 'b', + version: '2.0.1', + peerDependencies: { a: '^1.1.0' } + } + }; + + mockGetRepoChanges.mockReturnValue( + new Map([ + ['a/package.json', { mode: 'modified', newhash: 'new-a', oldhash: 'old-a', status: 'M' }], + ['b/package.json', { mode: 'modified', newhash: 'new-b', oldhash: 'old-b', status: 'M' }] + ]) + ); + mockGetBlobContentAsync.mockImplementation(({ blobSpec }) => + Promise.resolve(JSON.stringify(blobs[blobSpec])) + ); + + const changedProjects = await new ProjectChangeAnalyzer(rushConfiguration).getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal: new Terminal(new StringBufferTerminalProvider(true)), + excludeVersionOnlyChanges: true + }); + + expect(changedProjects.size).toBe(0); + }); + it('excludeVersionOnlyChanges does not exclude projects with non-version changes', async () => { const rootDir: string = resolve(__dirname, 'repo'); const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( @@ -1140,6 +1181,24 @@ describe(ProjectChangeAnalyzer.name, () => { }); describe('isPackageJsonVersionOnlyChange', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(__dirname, 'repo', 'rush.json') + ); + const changedProjectVersions: ReadonlyMap = new Map([['a', '1.1.0']]); + + function isVersionBump( + oldPackageJson: object, + newPackageJson: object, + bumpedVersions: ReadonlyMap = changedProjectVersions + ): boolean { + return isPackageJsonVersionOnlyChange( + JSON.stringify(oldPackageJson), + JSON.stringify(newPackageJson), + rushConfiguration.projectsByName, + bumpedVersions + ); + } + it('returns true when only version field changed', () => { const oldContent = JSON.stringify({ name: 'test-package', @@ -1174,6 +1233,88 @@ describe(ProjectChangeAnalyzer.name, () => { expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false); }); + it.each([ + ['dependencies', '1.0.0', '1.1.0'], + ['devDependencies', '~1.0.0', '~1.1.0'], + ['peerDependencies (exact)', '1.0.0', '1.1.0'], + ['peerDependencies (caret)', '^1.0.0', '^1.1.0'] + ])('returns true for a generated %s rewrite', (label, oldRange, newRange) => { + const dependencySection: string = label.split(' ')[0]; + expect( + isVersionBump( + { + name: 'consumer', + version: '2.0.0', + [dependencySection]: { a: oldRange } + }, + { + name: 'consumer', + version: '2.0.1', + [dependencySection]: { a: newRange } + } + ) + ).toBe(true); + }); + + it('returns false for an arbitrary local dependency range edit', () => { + expect( + isVersionBump( + { name: 'consumer', version: '2.0.0', dependencies: { a: '^1.0.0' } }, + { name: 'consumer', version: '2.0.1', dependencies: { a: '~1.1.0' } } + ) + ).toBe(false); + }); + + it.each([ + [{ a: '1.0.0' }, { a: '1.1.0', b: '2.0.0' }], + [{ a: '1.0.0', b: '2.0.0' }, { a: '1.1.0' }] + ])('returns false for dependency additions or removals', (oldDependencies, newDependencies) => { + expect( + isVersionBump( + { name: 'consumer', version: '2.0.0', dependencies: oldDependencies }, + { name: 'consumer', version: '2.0.1', dependencies: newDependencies } + ) + ).toBe(false); + }); + + it('returns false when an unrelated package field changes', () => { + expect( + isVersionBump( + { name: 'consumer', version: '2.0.0', description: 'old', dependencies: { a: '1.0.0' } }, + { name: 'consumer', version: '2.0.1', description: 'new', dependencies: { a: '1.1.0' } } + ) + ).toBe(false); + }); + + it('returns false for a non-local dependency rewrite', () => { + expect( + isVersionBump( + { name: 'consumer', version: '2.0.0', dependencies: { external: '1.0.0' } }, + { name: 'consumer', version: '2.0.1', dependencies: { external: '1.1.0' } }, + new Map([['external', '1.1.0']]) + ) + ).toBe(false); + }); + + it('returns false for a local dependency whose version did not change', () => { + expect( + isVersionBump( + { name: 'consumer', version: '2.0.0', dependencies: { a: '1.0.0' } }, + { name: 'consumer', version: '2.0.1', dependencies: { a: '1.1.0' } }, + new Map() + ) + ).toBe(false); + }); + + it('returns false when the package version did not change', () => { + expect( + isVersionBump( + { name: 'consumer', version: '2.0.0', dependencies: { a: '1.0.0' } }, + { name: 'consumer', version: '2.0.0', dependencies: { a: '1.1.0' } } + ) + ).toBe(false); + }); + it('returns false when version field is missing in old content', () => { const oldContent = JSON.stringify({ name: 'test-package',