Skip to content

Commit 57fbb6b

Browse files
Copilotdmichon-msft
andcommitted
Address review feedback - improve type safety, deduplicate code, add tests
- Add proper jest.MockedFunction types for mock functions - Add test for package.json + other file changes scenario - Deduplicate version-only check logic into helper function - Use Sort.sortBy for stable ordinal string comparison - Extract isPackageJsonVersionOnlyChange as exported helper - Add comprehensive unit tests for the helper function Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
1 parent ab2c187 commit 57fbb6b

File tree

2 files changed

+247
-62
lines changed

2 files changed

+247
-62
lines changed

libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts

Lines changed: 63 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as path from 'node:path';
66
import ignore, { type Ignore } from 'ignore';
77

88
import type { IReadonlyLookupByPath, LookupByPath } from '@rushstack/lookup-by-path';
9-
import { Path, FileSystem, Async, AlreadyReportedError } from '@rushstack/node-core-library';
9+
import { Path, FileSystem, Async, AlreadyReportedError, Sort } from '@rushstack/node-core-library';
1010
import {
1111
getRepoChanges,
1212
getRepoRoot,
@@ -118,6 +118,30 @@ export class ProjectChangeAnalyzer {
118118
> = this.getChangesByProject(lookup, changedFiles);
119119

120120
const changedProjects: Set<RushConfigurationProject> = new Set();
121+
122+
// Helper function to check and add project to changedProjects
123+
const checkAndAddProject = async (
124+
project: RushConfigurationProject,
125+
projectChanges: Map<string, IFileDiffStatus>
126+
): Promise<void> => {
127+
if (projectChanges.size > 0) {
128+
// Check for version-only changes if excludeVersionOnlyChanges is enabled
129+
if (excludeVersionOnlyChanges && projectChanges.size === 1) {
130+
const packageJsonPath: string = Path.convertToSlashes(
131+
path.relative(repoRoot, path.join(project.projectFolder, 'package.json'))
132+
);
133+
const diffStatus: IFileDiffStatus | undefined = projectChanges.get(packageJsonPath);
134+
if (diffStatus) {
135+
const isVersionOnlyChange: boolean = await this._isVersionOnlyChangeAsync(diffStatus, repoRoot);
136+
if (isVersionOnlyChange) {
137+
return; // Skip adding this project
138+
}
139+
}
140+
}
141+
changedProjects.add(project);
142+
}
143+
};
144+
121145
if (enableFiltering) {
122146
// Reading rush-project.json may be problematic if, e.g. rush install has not yet occurred and rigs are in use
123147
await Async.forEachAsync(
@@ -130,51 +154,15 @@ export class ProjectChangeAnalyzer {
130154
terminal
131155
);
132156

133-
if (filteredChanges.size > 0) {
134-
// Check for version-only changes if excludeVersionOnlyChanges is enabled
135-
if (excludeVersionOnlyChanges && filteredChanges.size === 1) {
136-
const packageJsonPath: string = Path.convertToSlashes(
137-
path.relative(repoRoot, path.join(project.projectFolder, 'package.json'))
138-
);
139-
const diffStatus: IFileDiffStatus | undefined = filteredChanges.get(packageJsonPath);
140-
if (diffStatus) {
141-
const isVersionOnlyChange: boolean = await this._isVersionOnlyChangeAsync(
142-
diffStatus,
143-
repoRoot
144-
);
145-
if (isVersionOnlyChange) {
146-
return; // Skip adding this project
147-
}
148-
}
149-
}
150-
changedProjects.add(project);
151-
}
157+
await checkAndAddProject(project, filteredChanges);
152158
},
153159
{ concurrency: 10 }
154160
);
155161
} else {
156162
await Async.forEachAsync(
157163
changesByProject,
158164
async ([project, projectChanges]) => {
159-
if (projectChanges.size > 0) {
160-
// Check for version-only changes if excludeVersionOnlyChanges is enabled
161-
if (excludeVersionOnlyChanges && projectChanges.size === 1) {
162-
const packageJsonPath: string = Path.convertToSlashes(
163-
path.relative(repoRoot, path.join(project.projectFolder, 'package.json'))
164-
);
165-
const diffStatus: IFileDiffStatus | undefined = projectChanges.get(packageJsonPath);
166-
if (diffStatus) {
167-
const isVersionOnlyChange: boolean = await this._isVersionOnlyChangeAsync(
168-
diffStatus,
169-
repoRoot
170-
);
171-
if (isVersionOnlyChange) {
172-
return; // Skip adding this project
173-
}
174-
}
175-
}
176-
changedProjects.add(project);
177-
}
165+
await checkAndAddProject(project, projectChanges);
178166
},
179167
{ concurrency: 10 }
180168
);
@@ -263,9 +251,8 @@ export class ProjectChangeAnalyzer {
263251
}
264252

265253
// Sort the set by projectRelativeFolder to avoid race conditions in the results
266-
const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects).sort((a, b) =>
267-
a.projectRelativeFolder.localeCompare(b.projectRelativeFolder)
268-
);
254+
const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects);
255+
Sort.sortBy(sortedChangedProjects, (project) => project.projectRelativeFolder);
269256

270257
return new Set(sortedChangedProjects);
271258
}
@@ -473,21 +460,7 @@ export class ProjectChangeAnalyzer {
473460
repositoryRoot: repoRoot
474461
});
475462

476-
// Parse both versions
477-
const oldPackageJson: Record<string, unknown> = JSON.parse(oldPackageJsonContent);
478-
const currentPackageJson: Record<string, unknown> = JSON.parse(currentPackageJsonContent);
479-
480-
// Ensure both have a version field
481-
if (!oldPackageJson.version || !currentPackageJson.version) {
482-
return false;
483-
}
484-
485-
// Remove the version field from both (no need to clone, these are fresh objects from JSON.parse)
486-
oldPackageJson.version = undefined;
487-
currentPackageJson.version = undefined;
488-
489-
// Compare the objects without the version field
490-
return JSON.stringify(oldPackageJson) === JSON.stringify(currentPackageJson);
463+
return isPackageJsonVersionOnlyChange(oldPackageJsonContent, currentPackageJsonContent);
491464
} catch (error) {
492465
// If we can't read the file or parse it, assume it's not a version-only change
493466
return false;
@@ -612,3 +585,36 @@ async function getAdditionalFilesFromRushProjectConfigurationAsync(
612585

613586
return additionalFilesFromRushProjectConfiguration;
614587
}
588+
589+
/**
590+
* Compares two package.json file contents and determines if the only difference is the "version" field.
591+
* @param oldPackageJsonContent - The old package.json content as a string
592+
* @param newPackageJsonContent - The new package.json content as a string
593+
* @returns true if the only difference is the version field, false otherwise
594+
* @public
595+
*/
596+
export function isPackageJsonVersionOnlyChange(
597+
oldPackageJsonContent: string,
598+
newPackageJsonContent: string
599+
): boolean {
600+
try {
601+
// Parse both versions
602+
const oldPackageJson: Record<string, unknown> = JSON.parse(oldPackageJsonContent);
603+
const newPackageJson: Record<string, unknown> = JSON.parse(newPackageJsonContent);
604+
605+
// Ensure both have a version field
606+
if (!oldPackageJson.version || !newPackageJson.version) {
607+
return false;
608+
}
609+
610+
// Remove the version field from both (no need to clone, these are fresh objects from JSON.parse)
611+
oldPackageJson.version = undefined;
612+
newPackageJson.version = undefined;
613+
614+
// Compare the objects without the version field
615+
return JSON.stringify(oldPackageJson) === JSON.stringify(newPackageJson);
616+
} catch (error) {
617+
// If we can't parse the JSON, assume it's not a version-only change
618+
return false;
619+
}
620+
}

libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts

Lines changed: 184 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ const mockHashes: Map<string, string> = new Map([
2323
]);
2424

2525
// Mock function for customizing repo changes in each test
26-
const mockGetRepoChanges = jest.fn<Map<string, IFileDiffStatus>, []>();
26+
const mockGetRepoChanges: jest.MockedFunction<typeof import('@rushstack/package-deps-hash').getRepoChanges> =
27+
jest.fn();
2728

2829
jest.mock(`@rushstack/package-deps-hash`, () => {
2930
return {
@@ -47,16 +48,22 @@ jest.mock(`@rushstack/package-deps-hash`, () => {
4748
hashFilesAsync(rootDirectory: string, filePaths: Iterable<string>): ReadonlyMap<string, string> {
4849
return new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath]));
4950
},
50-
getRepoChanges(): Map<string, IFileDiffStatus> {
51-
return mockGetRepoChanges();
51+
getRepoChanges(
52+
currentWorkingDirectory: string,
53+
revision?: string,
54+
gitPath?: string
55+
): Map<string, IFileDiffStatus> {
56+
return mockGetRepoChanges(currentWorkingDirectory, revision, gitPath);
5257
}
5358
};
5459
});
5560

5661
const { Git: OriginalGit } = jest.requireActual('../Git');
5762

5863
// Mock function for getBlobContentAsync to be customized in each test
59-
const mockGetBlobContentAsync = jest.fn<Promise<string>, [{ blobSpec: string; repositoryRoot: string }]>();
64+
const mockGetBlobContentAsync: jest.MockedFunction<
65+
typeof import('../Git').Git.prototype.getBlobContentAsync
66+
> = jest.fn();
6067

6168
/** Mock Git to test `getChangedProjectsAsync` */
6269
jest.mock('../Git', () => {
@@ -110,7 +117,7 @@ import { resolve } from 'node:path';
110117
import type { IDetailedRepoState, IFileDiffStatus } from '@rushstack/package-deps-hash';
111118
import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';
112119

113-
import { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer';
120+
import { ProjectChangeAnalyzer, isPackageJsonVersionOnlyChange } from '../ProjectChangeAnalyzer';
114121
import { RushConfiguration } from '../../api/RushConfiguration';
115122
import type {
116123
IInputsSnapshot,
@@ -370,6 +377,178 @@ describe(ProjectChangeAnalyzer.name, () => {
370377
});
371378
expect(changedProjects.has(rushConfiguration.getProjectByName('b')!)).toBe(true);
372379
});
380+
381+
it('excludeVersionOnlyChanges does not exclude projects when package.json and other files changed', async () => {
382+
const rootDir: string = resolve(__dirname, 'repo');
383+
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
384+
resolve(rootDir, 'rush.json')
385+
);
386+
387+
// Mock package.json with only version change
388+
const oldPackageJsonContent = JSON.stringify(
389+
{
390+
name: 'c',
391+
version: '1.0.0',
392+
description: 'Test package',
393+
dependencies: {
394+
a: '1.0.0'
395+
}
396+
},
397+
null,
398+
2
399+
);
400+
401+
const newPackageJsonContent = JSON.stringify(
402+
{
403+
name: 'c',
404+
version: '1.0.1',
405+
description: 'Test package',
406+
dependencies: {
407+
a: '1.0.0'
408+
}
409+
},
410+
null,
411+
2
412+
);
413+
414+
// Set up mock repo changes - package.json AND another file changed for project 'c'
415+
mockGetRepoChanges.mockReturnValue(
416+
new Map<string, IFileDiffStatus>([
417+
[
418+
'c/package.json',
419+
{
420+
mode: 'modified',
421+
newhash: 'newhash3',
422+
oldhash: 'oldhash3',
423+
status: 'M'
424+
}
425+
],
426+
[
427+
'c/src/index.ts',
428+
{
429+
mode: 'modified',
430+
newhash: 'newhash4',
431+
oldhash: 'oldhash4',
432+
status: 'M'
433+
}
434+
]
435+
])
436+
);
437+
438+
// Mock the blob content to return different versions based on the hash
439+
mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => {
440+
if (opts.blobSpec === 'oldhash3') {
441+
return Promise.resolve(oldPackageJsonContent);
442+
} else if (opts.blobSpec === 'newhash3') {
443+
return Promise.resolve(newPackageJsonContent);
444+
}
445+
return Promise.resolve('');
446+
});
447+
448+
const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration);
449+
const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
450+
const terminal: Terminal = new Terminal(terminalProvider);
451+
452+
// Test with excludeVersionOnlyChanges - project should still be detected as changed because multiple files changed
453+
const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({
454+
enableFiltering: false,
455+
includeExternalDependencies: false,
456+
targetBranchName: 'main',
457+
terminal,
458+
excludeVersionOnlyChanges: true
459+
});
460+
expect(changedProjects.has(rushConfiguration.getProjectByName('c')!)).toBe(true);
461+
});
462+
});
463+
464+
describe('isPackageJsonVersionOnlyChange', () => {
465+
it('returns true when only version field changed', () => {
466+
const oldContent = JSON.stringify({
467+
name: 'test-package',
468+
version: '1.0.0',
469+
description: 'Test package',
470+
dependencies: { foo: '1.0.0' }
471+
});
472+
const newContent = JSON.stringify({
473+
name: 'test-package',
474+
version: '1.0.1',
475+
description: 'Test package',
476+
dependencies: { foo: '1.0.0' }
477+
});
478+
479+
expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(true);
480+
});
481+
482+
it('returns false when other fields changed', () => {
483+
const oldContent = JSON.stringify({
484+
name: 'test-package',
485+
version: '1.0.0',
486+
description: 'Test package',
487+
dependencies: { foo: '1.0.0' }
488+
});
489+
const newContent = JSON.stringify({
490+
name: 'test-package',
491+
version: '1.0.1',
492+
description: 'Test package',
493+
dependencies: { foo: '1.0.1' }
494+
});
495+
496+
expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false);
497+
});
498+
499+
it('returns false when version field is missing in old content', () => {
500+
const oldContent = JSON.stringify({
501+
name: 'test-package',
502+
description: 'Test package'
503+
});
504+
const newContent = JSON.stringify({
505+
name: 'test-package',
506+
version: '1.0.1',
507+
description: 'Test package'
508+
});
509+
510+
expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false);
511+
});
512+
513+
it('returns false when version field is missing in new content', () => {
514+
const oldContent = JSON.stringify({
515+
name: 'test-package',
516+
version: '1.0.0',
517+
description: 'Test package'
518+
});
519+
const newContent = JSON.stringify({
520+
name: 'test-package',
521+
description: 'Test package'
522+
});
523+
524+
expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false);
525+
});
526+
527+
it('returns false when JSON is invalid', () => {
528+
const oldContent = 'invalid json';
529+
const newContent = '{ "name": "test" }';
530+
531+
expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(false);
532+
});
533+
534+
it('returns true even with whitespace differences', () => {
535+
const oldContent = JSON.stringify(
536+
{
537+
name: 'test-package',
538+
version: '1.0.0',
539+
description: 'Test package'
540+
},
541+
null,
542+
2
543+
);
544+
const newContent = JSON.stringify({
545+
name: 'test-package',
546+
version: '1.0.1',
547+
description: 'Test package'
548+
});
549+
550+
expect(isPackageJsonVersionOnlyChange(oldContent, newContent)).toBe(true);
551+
});
373552
});
374553
});
375554

0 commit comments

Comments
 (0)