diff --git a/common/changes/@microsoft/rush/exclude-version-only-changes_2026-02-04-23-11.json b/common/changes/@microsoft/rush/exclude-version-only-changes_2026-02-04-23-11.json new file mode 100644 index 00000000000..07495336612 --- /dev/null +++ b/common/changes/@microsoft/rush/exclude-version-only-changes_2026-02-04-23-11.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix `rush change --verify` to ignore version-only changes in package.json files and changes to CHANGELOG.md and CHANGELOG.json files, preventing false positives after `rush version --bump` updates package versions and changelogs.", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "copilot@github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index caa4928eba5..471a2e19f6f 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -497,6 +497,7 @@ export interface IGenerateCacheEntryIdOptions { // @beta (undocumented) export interface IGetChangedProjectsOptions { enableFiltering: boolean; + excludeVersionOnlyChanges?: boolean; includeExternalDependencies: boolean; // (undocumented) shouldFetch?: boolean; diff --git a/libraries/rush-lib/src/cli/actions/ChangeAction.ts b/libraries/rush-lib/src/cli/actions/ChangeAction.ts index ed4ab9486f4..9167d5bca15 100644 --- a/libraries/rush-lib/src/cli/actions/ChangeAction.ts +++ b/libraries/rush-lib/src/cli/actions/ChangeAction.ts @@ -366,7 +366,9 @@ export class ChangeAction extends BaseRushAction { // Not enabling, since this would be a breaking change includeExternalDependencies: false, // Since install may not have happened, cannot read rush-project.json - enableFiltering: false + enableFiltering: false, + // Exclude version-only changes to prevent 'rush version --bump' from triggering 'rush change --verify' + excludeVersionOnlyChanges: true }); const projectHostMap: Map = this._generateHostMap(); diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index fdb021acb2a..bec7b6b8fb1 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -5,8 +5,8 @@ import * as path from 'node:path'; import ignore, { type Ignore } from 'ignore'; -import type { IReadonlyLookupByPath, LookupByPath } from '@rushstack/lookup-by-path'; -import { Path, FileSystem, Async, AlreadyReportedError } from '@rushstack/node-core-library'; +import type { IReadonlyLookupByPath, LookupByPath, IPrefixMatch } from '@rushstack/lookup-by-path'; +import { Path, FileSystem, Async, AlreadyReportedError, Sort } from '@rushstack/node-core-library'; import { getRepoChanges, getRepoRoot, @@ -50,6 +50,16 @@ export interface IGetChangedProjectsOptions { * and exclude matched files from change detection. */ enableFiltering: boolean; + + /** + * If set to `true`, excludes projects where the only changes are: + * - A version-only change to `package.json` (only the "version" field differs) + * - Changes to `CHANGELOG.md` and/or `CHANGELOG.json` files + * + * This prevents `rush version --bump` from triggering `rush change --verify` to request change files + * for the version bumps and changelog updates it creates. + */ + excludeVersionOnlyChanges?: boolean; } /** @@ -83,8 +93,15 @@ export class ProjectChangeAnalyzer { ): Promise> { const { _rushConfiguration: rushConfiguration } = this; - const { targetBranchName, terminal, includeExternalDependencies, enableFiltering, shouldFetch, variant } = - options; + const { + targetBranchName, + terminal, + includeExternalDependencies, + enableFiltering, + shouldFetch, + variant, + excludeVersionOnlyChanges + } = options; const gitPath: string = this._git.getGitPathOrThrow(); const repoRoot: string = getRepoRoot(rushConfiguration.rushJsonFolder); @@ -104,6 +121,55 @@ export class ProjectChangeAnalyzer { > = this.getChangesByProject(lookup, changedFiles); const changedProjects: Set = new Set(); + + // Helper function to check and add project to changedProjects + const checkAndAddProject = async ( + project: RushConfigurationProject, + projectChanges: Map + ): Promise => { + // Early return if no changes + if (projectChanges.size === 0) { + return; + } + + // If excludeVersionOnlyChanges is not enabled, add the project + if (!excludeVersionOnlyChanges) { + changedProjects.add(project); + return; + } + + // Filter out package.json with version-only changes, CHANGELOG.md, and CHANGELOG.json + for (const [filePath, diffStatus] of projectChanges) { + // Use lookup to find the project-relative path + const match: IPrefixMatch | undefined = + lookup.findLongestPrefixMatch(filePath); + if (!match) { + // This should be unreachable as projectChanges contains files where match.value === project + changedProjects.add(project); + return; + } + + const projectRelativePath: string = filePath.slice(match.index); + + // Skip CHANGELOG.md and CHANGELOG.json files at project root + if (projectRelativePath === '/CHANGELOG.md' || projectRelativePath === '/CHANGELOG.json') { + continue; + } + + // Check if this is package.json at project root with version-only changes + if (projectRelativePath === '/package.json') { + const isVersionOnlyChange: boolean = await this._isVersionOnlyChangeAsync(diffStatus, repoRoot); + if (isVersionOnlyChange) { + continue; // Skip version-only package.json changes + } + } + + // Found a non-excluded change, add the project + changedProjects.add(project); + return; + } + }; + if (enableFiltering) { // Reading rush-project.json may be problematic if, e.g. rush install has not yet occurred and rigs are in use await Async.forEachAsync( @@ -116,18 +182,18 @@ export class ProjectChangeAnalyzer { terminal ); - if (filteredChanges.size > 0) { - changedProjects.add(project); - } + await checkAndAddProject(project, filteredChanges); }, { concurrency: 10 } ); } else { - for (const [project, projectChanges] of changesByProject) { - if (projectChanges.size > 0) { - changedProjects.add(project); - } - } + await Async.forEachAsync( + changesByProject, + async ([project, projectChanges]) => { + await checkAndAddProject(project, projectChanges); + }, + { concurrency: 10 } + ); } // External dependency changes are not allowed to be filtered, so add these after filtering @@ -212,7 +278,11 @@ export class ProjectChangeAnalyzer { }); } - return changedProjects; + // Sort the set by projectRelativeFolder to avoid race conditions in the results + const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects); + Sort.sortBy(sortedChangedProjects, (project) => project.projectRelativeFolder); + + return new Set(sortedChangedProjects); } protected getChangesByProject( @@ -395,6 +465,36 @@ export class ProjectChangeAnalyzer { } } + /** + * Checks if the only change to a package.json file is to the "version" field. + * @internal + */ + private async _isVersionOnlyChangeAsync(diffStatus: IFileDiffStatus, repoRoot: string): Promise { + try { + // Only check modified files, not additions or deletions + if (diffStatus.status !== 'M') { + return false; + } + + // Get the old version of package.json from Git using the blob id from IFileDiffStatus + const oldPackageJsonContent: string = await this._git.getBlobContentAsync({ + blobSpec: diffStatus.oldhash, + repositoryRoot: repoRoot + }); + + // Get the current version of package.json from Git (staged/committed version, not working tree) + const currentPackageJsonContent: string = await this._git.getBlobContentAsync({ + blobSpec: diffStatus.newhash, + repositoryRoot: repoRoot + }); + + return isPackageJsonVersionOnlyChange(oldPackageJsonContent, currentPackageJsonContent); + } catch (error) { + // If we can't read the file or parse it, assume it's not a version-only change + return false; + } + } + /** * @internal */ @@ -513,3 +613,36 @@ async function getAdditionalFilesFromRushProjectConfigurationAsync( return additionalFilesFromRushProjectConfiguration; } + +/** + * Compares two package.json file contents and determines if the only difference is the "version" field. + * @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 + * @public + */ +export function isPackageJsonVersionOnlyChange( + oldPackageJsonContent: string, + newPackageJsonContent: string +): 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) { + 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; + + // Compare the objects without the version field + return JSON.stringify(oldPackageJson) === JSON.stringify(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 b90254d5652..7be396977c9 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -21,6 +21,11 @@ const mockHashes: Map = new Map([ ['j/package.json', 'hash17'], ['rush.json', 'hash18'] ]); + +// Mock function for customizing repo changes in each test +const mockGetRepoChanges: jest.MockedFunction = + jest.fn(); + jest.mock(`@rushstack/package-deps-hash`, () => { return { getRepoRoot(dir: string): string { @@ -43,34 +48,23 @@ jest.mock(`@rushstack/package-deps-hash`, () => { hashFilesAsync(rootDirectory: string, filePaths: Iterable): ReadonlyMap { return new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath])); }, - getRepoChanges(): Map { - return new Map([ - [ - // Test subspace lockfile change detection - 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-lock.yaml', - { - mode: 'modified', - newhash: 'newhash', - oldhash: 'oldhash', - status: 'M' - } - ], - [ - // Test lockfile deletion detection - 'common/config/subspaces/default/pnpm-lock.yaml', - { - mode: 'deleted', - newhash: '', - oldhash: 'oldhash', - status: 'D' - } - ] - ]); + getRepoChanges( + currentWorkingDirectory: string, + revision?: string, + gitPath?: string + ): Map { + return mockGetRepoChanges(currentWorkingDirectory, revision, gitPath); } }; }); const { Git: OriginalGit } = jest.requireActual('../Git'); + +// Mock function for getBlobContentAsync to be customized in each test +const mockGetBlobContentAsync: jest.MockedFunction< + typeof import('../Git').Git.prototype.getBlobContentAsync +> = jest.fn(); + /** Mock Git to test `getChangedProjectsAsync` */ jest.mock('../Git', () => { return { @@ -82,7 +76,7 @@ jest.mock('../Git', () => { return 'merge-base-sha'; } public async getBlobContentAsync(opts: { blobSpec: string; repositoryRoot: string }): Promise { - return ''; + return mockGetBlobContentAsync(opts); } } }; @@ -123,7 +117,7 @@ import { resolve } from 'node:path'; import type { IDetailedRepoState, IFileDiffStatus } from '@rushstack/package-deps-hash'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; -import { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer'; +import { ProjectChangeAnalyzer, isPackageJsonVersionOnlyChange } from '../ProjectChangeAnalyzer'; import { RushConfiguration } from '../../api/RushConfiguration'; import type { IInputsSnapshot, @@ -139,6 +133,8 @@ import type { describe(ProjectChangeAnalyzer.name, () => { beforeEach(() => { mockSnapshot.mockClear(); + mockGetBlobContentAsync.mockClear(); + mockGetRepoChanges.mockClear(); }); describe(ProjectChangeAnalyzer.prototype._tryGetSnapshotProviderAsync.name, () => { @@ -172,6 +168,32 @@ describe(ProjectChangeAnalyzer.name, () => { describe(ProjectChangeAnalyzer.prototype.getChangedProjectsAsync.name, () => { it('Subspaces detects external changes', async () => { + // Set up mock repo changes for this test + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + // Test subspace lockfile change detection + 'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-lock.yaml', + { + mode: 'modified', + newhash: 'newhash', + oldhash: 'oldhash', + status: 'M' + } + ], + [ + // Test lockfile deletion detection + 'common/config/subspaces/default/pnpm-lock.yaml', + { + mode: 'deleted', + newhash: '', + oldhash: 'oldhash', + status: 'D' + } + ] + ]) + ); + const rootDir: string = resolve(__dirname, 'repoWithSubspaces'); const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( resolve(rootDir, 'rush.json') @@ -200,6 +222,457 @@ describe(ProjectChangeAnalyzer.name, () => { expect(changedProjects.has(rushConfiguration.getProjectByName(projectName)!)).toBe(false); }); }); + + it('excludeVersionOnlyChanges excludes projects with only version field changes', async () => { + const rootDir: string = resolve(__dirname, 'repo'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // Mock package.json with only version change + const oldPackageJsonContent = JSON.stringify( + { + name: 'a', + version: '1.0.0', + description: 'Test package', + dependencies: { + b: '1.0.0' + } + }, + null, + 2 + ); + + const newPackageJsonContent = JSON.stringify( + { + name: 'a', + version: '1.0.1', + description: 'Test package', + dependencies: { + b: '1.0.0' + } + }, + null, + 2 + ); + + // Set up mock repo changes - only package.json changed for project 'a' + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'a/package.json', + { + mode: 'modified', + newhash: 'newhash1', + oldhash: 'oldhash1', + status: 'M' + } + ] + ]) + ); + + // Mock the blob content to return different versions based on the hash + mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => { + if (opts.blobSpec === 'oldhash1') { + return Promise.resolve(oldPackageJsonContent); + } else if (opts.blobSpec === 'newhash1') { + return Promise.resolve(newPackageJsonContent); + } + return Promise.resolve(''); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + + // Test without excludeVersionOnlyChanges - project should be detected as changed + const changedProjectsWithoutExclude = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal + }); + expect(changedProjectsWithoutExclude.has(rushConfiguration.getProjectByName('a')!)).toBe(true); + + // Test with excludeVersionOnlyChanges - project should NOT be detected as changed + const changedProjectsWithExclude = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal, + excludeVersionOnlyChanges: true + }); + expect(changedProjectsWithExclude.has(rushConfiguration.getProjectByName('a')!)).toBe(false); + }); + + it('excludeVersionOnlyChanges does not exclude projects with non-version changes', async () => { + const rootDir: string = resolve(__dirname, 'repo'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // Mock package.json with version AND dependency change + const oldPackageJsonContent = JSON.stringify( + { + name: 'b', + version: '1.0.0', + description: 'Test package', + dependencies: { + a: '1.0.0' + } + }, + null, + 2 + ); + + const newPackageJsonContent = JSON.stringify( + { + name: 'b', + version: '1.0.1', + description: 'Test package', + dependencies: { + a: '1.0.1' // Dependency version also changed + } + }, + null, + 2 + ); + + // Set up mock repo changes - only package.json changed for project 'b' + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'b/package.json', + { + mode: 'modified', + newhash: 'newhash2', + oldhash: 'oldhash2', + status: 'M' + } + ] + ]) + ); + + // Mock the blob content to return different versions based on the hash + mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => { + if (opts.blobSpec === 'oldhash2') { + return Promise.resolve(oldPackageJsonContent); + } else if (opts.blobSpec === 'newhash2') { + return Promise.resolve(newPackageJsonContent); + } + return Promise.resolve(''); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + + // Test with excludeVersionOnlyChanges - project should still be detected as changed + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal, + excludeVersionOnlyChanges: true + }); + expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true); + }); + + it('excludeVersionOnlyChanges does not exclude projects when package.json and other files changed', async () => { + const rootDir: string = resolve(__dirname, 'repo'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // Mock package.json with only version change + const oldPackageJsonContent = JSON.stringify( + { + name: 'c', + version: '1.0.0', + description: 'Test package', + dependencies: { + a: '1.0.0' + } + }, + null, + 2 + ); + + const newPackageJsonContent = JSON.stringify( + { + name: 'c', + version: '1.0.1', + description: 'Test package', + dependencies: { + a: '1.0.0' + } + }, + null, + 2 + ); + + // Set up mock repo changes - package.json AND another file changed for project 'c' + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'c/package.json', + { + mode: 'modified', + newhash: 'newhash3', + oldhash: 'oldhash3', + status: 'M' + } + ], + [ + 'c/src/index.ts', + { + mode: 'modified', + newhash: 'newhash4', + oldhash: 'oldhash4', + status: 'M' + } + ] + ]) + ); + + // Mock the blob content to return different versions based on the hash + mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => { + if (opts.blobSpec === 'oldhash3') { + return Promise.resolve(oldPackageJsonContent); + } else if (opts.blobSpec === 'newhash3') { + return Promise.resolve(newPackageJsonContent); + } + return Promise.resolve(''); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + + // Test with excludeVersionOnlyChanges - project should still be detected as changed because multiple files changed + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal, + excludeVersionOnlyChanges: true + }); + expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(true); + }); + + it('excludeVersionOnlyChanges ignores CHANGELOG.md and CHANGELOG.json files', async () => { + const rootDir: string = resolve(__dirname, 'repo'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // Mock package.json with only version change + const oldPackageJsonContent = JSON.stringify({ + name: 'd', + version: '1.0.0', + description: 'Test package' + }); + + const newPackageJsonContent = JSON.stringify({ + name: 'd', + version: '1.0.1', + description: 'Test package' + }); + + // Set up mock repo changes - package.json (version only), CHANGELOG.md, and CHANGELOG.json changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'd/package.json', + { + mode: 'modified', + newhash: 'newhash4', + oldhash: 'oldhash4', + status: 'M' + } + ], + [ + 'd/CHANGELOG.md', + { + mode: 'modified', + newhash: 'newhash5', + oldhash: 'oldhash5', + status: 'M' + } + ], + [ + 'd/CHANGELOG.json', + { + mode: 'modified', + newhash: 'newhash6', + oldhash: 'oldhash6', + status: 'M' + } + ] + ]) + ); + + // Mock the blob content to return different versions based on the hash + mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => { + if (opts.blobSpec === 'oldhash4') { + return Promise.resolve(oldPackageJsonContent); + } else if (opts.blobSpec === 'newhash4') { + return Promise.resolve(newPackageJsonContent); + } + return Promise.resolve(''); + }); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + + // Test with excludeVersionOnlyChanges - project should NOT be detected as changed + // because only version-only package.json and CHANGELOG files changed + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal, + excludeVersionOnlyChanges: true + }); + expect(changedProjects.has(rushConfiguration.getProjectByName('d')!)).toBe(false); + }); + + it('excludeVersionOnlyChanges does not ignore projects with CHANGELOG and other substantive changes', async () => { + const rootDir: string = resolve(__dirname, 'repo'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + resolve(rootDir, 'rush.json') + ); + + // Set up mock repo changes - CHANGELOG.md and src file changed + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + 'e/CHANGELOG.md', + { + mode: 'modified', + newhash: 'newhash7', + oldhash: 'oldhash7', + status: 'M' + } + ], + [ + 'e/src/index.ts', + { + mode: 'modified', + newhash: 'newhash8', + oldhash: 'oldhash8', + status: 'M' + } + ] + ]) + ); + + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + + // Test with excludeVersionOnlyChanges - project should be detected as changed + // because there's a substantive change in addition to CHANGELOG + const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal, + excludeVersionOnlyChanges: true + }); + expect(changedProjects.has(rushConfiguration.getProjectByName('e')!)).toBe(true); + }); + }); + + describe('isPackageJsonVersionOnlyChange', () => { + it('returns true when only version field changed', () => { + const oldContent = JSON.stringify({ + name: 'test-package', + version: '1.0.0', + description: 'Test package', + dependencies: { foo: '1.0.0' } + }); + const newContent = JSON.stringify({ + name: 'test-package', + version: '1.0.1', + description: 'Test package', + dependencies: { foo: '1.0.0' } + }); + + expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(true); + }); + + it('returns false when other fields changed', () => { + const oldContent = JSON.stringify({ + name: 'test-package', + version: '1.0.0', + description: 'Test package', + dependencies: { foo: '1.0.0' } + }); + const newContent = JSON.stringify({ + name: 'test-package', + version: '1.0.1', + description: 'Test package', + dependencies: { foo: '1.0.1' } + }); + + expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false); + }); + + it('returns false when version field is missing in old content', () => { + const oldContent = JSON.stringify({ + name: 'test-package', + description: 'Test package' + }); + const newContent = JSON.stringify({ + name: 'test-package', + version: '1.0.1', + description: 'Test package' + }); + + expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false); + }); + + it('returns false when version field is missing in new content', () => { + const oldContent = JSON.stringify({ + name: 'test-package', + version: '1.0.0', + description: 'Test package' + }); + const newContent = JSON.stringify({ + name: 'test-package', + description: 'Test package' + }); + + expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false); + }); + + it('returns false when JSON is invalid', () => { + const oldContent = 'invalid json'; + const newContent = '{ "name": "test" }'; + + expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false); + }); + + it('returns true even with whitespace differences', () => { + const oldContent = JSON.stringify( + { + name: 'test-package', + version: '1.0.0', + description: 'Test package' + }, + null, + 2 + ); + const newContent = JSON.stringify({ + name: 'test-package', + version: '1.0.1', + description: 'Test package' + }); + + expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(true); + }); }); });