From c6b5e1d5d6ad651fd613bddd0fce18edbc931c92 Mon Sep 17 00:00:00 2001 From: Kenrick Date: Fri, 13 Sep 2024 07:47:18 +0800 Subject: [PATCH] [rush-lib] fix: update shrinkwrap when globalPackageExtensions has been changed (#4913) * fix: update shrinkwrap when globalPackageExtensions has been changed * fix: code review * fix: code review * refactor: simplify codes & test cases --- ...ap-packageExtensions_2024-09-06-03-55.json | 10 ++++ ...ap-packageExtensions_2024-09-06-03-55.json | 10 ++++ common/reviews/api/node-core-library.api.md | 1 + libraries/node-core-library/src/Sort.ts | 56 +++++++++++++++++++ .../node-core-library/src/test/Sort.test.ts | 47 ++++++++++++++++ .../installManager/WorkspaceInstallManager.ts | 38 ++++++++++++- .../src/logic/pnpm/PnpmShrinkwrapFile.ts | 4 ++ 7 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 common/changes/@microsoft/rush/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json create mode 100644 common/changes/@rushstack/node-core-library/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json diff --git a/common/changes/@microsoft/rush/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json b/common/changes/@microsoft/rush/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json new file mode 100644 index 00000000000..562bec8e668 --- /dev/null +++ b/common/changes/@microsoft/rush/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Always update shrinkwrap when `globalPackageExtensions` in `common/config/rush/pnpm-config.json` has been changed.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/node-core-library/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json b/common/changes/@rushstack/node-core-library/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json new file mode 100644 index 00000000000..c63a92f4177 --- /dev/null +++ b/common/changes/@rushstack/node-core-library/kenrick-fix-update-shrinkwrap-packageExtensions_2024-09-06-03-55.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Add a `Sort.sortKeys` function for sorting keys in an object", + "type": "minor" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index 842a74830bb..8e2f99a6cd1 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -838,6 +838,7 @@ export class Sort { static isSorted(collection: Iterable, comparer?: (x: any, y: any) => number): boolean; static isSortedBy(collection: Iterable, keySelector: (element: T) => any, comparer?: (x: any, y: any) => number): boolean; static sortBy(array: T[], keySelector: (element: T) => any, comparer?: (x: any, y: any) => number): void; + static sortKeys> | unknown[]>(object: T): T; static sortMapKeys(map: Map, keyComparer?: (x: K, y: K) => number): void; static sortSet(set: Set, comparer?: (x: T, y: T) => number): void; static sortSetBy(set: Set, keySelector: (element: T) => any, keyComparer?: (x: T, y: T) => number): void; diff --git a/libraries/node-core-library/src/Sort.ts b/libraries/node-core-library/src/Sort.ts index 77ff843c67a..8994db1fb39 100644 --- a/libraries/node-core-library/src/Sort.ts +++ b/libraries/node-core-library/src/Sort.ts @@ -231,4 +231,60 @@ export class Sort { set.add(item); } } + + /** + * Sort the keys deeply given an object or an array. + * + * Doesn't handle cyclic reference. + * + * @param object - The object to be sorted + * + * @example + * + * ```ts + * console.log(Sort.sortKeys({ c: 3, b: 2, a: 1 })); // { a: 1, b: 2, c: 3} + * ``` + */ + public static sortKeys> | unknown[]>(object: T): T { + if (!isPlainObject(object) && !Array.isArray(object)) { + throw new TypeError(`Expected object or array`); + } + + return Array.isArray(object) ? (innerSortArray(object) as T) : (innerSortKeys(object) as T); + } +} + +function isPlainObject(obj: unknown): obj is object { + return obj !== null && typeof obj === 'object'; +} + +function innerSortArray(arr: unknown[]): unknown[] { + const result: unknown[] = []; + for (const entry of arr) { + if (Array.isArray(entry)) { + result.push(innerSortArray(entry)); + } else if (isPlainObject(entry)) { + result.push(innerSortKeys(entry)); + } else { + result.push(entry); + } + } + return result; +} + +function innerSortKeys(obj: Partial>): Partial> { + const result: Partial> = {}; + const keys: string[] = Object.keys(obj).sort(); + for (const key of keys) { + const value: unknown = obj[key]; + if (Array.isArray(value)) { + result[key] = innerSortArray(value); + } else if (isPlainObject(value)) { + result[key] = innerSortKeys(value); + } else { + result[key] = value; + } + } + + return result; } diff --git a/libraries/node-core-library/src/test/Sort.test.ts b/libraries/node-core-library/src/test/Sort.test.ts index 997e5f344c1..b87eef16a37 100644 --- a/libraries/node-core-library/src/test/Sort.test.ts +++ b/libraries/node-core-library/src/test/Sort.test.ts @@ -69,3 +69,50 @@ test('Sort.sortSet', () => { Sort.sortSet(set); expect(Array.from(set)).toEqual(['aardvark', 'goose', 'zebra']); }); + +describe('Sort.sortKeys', () => { + test('Simple object', () => { + const unsortedObj = { q: 0, p: 0, r: 0 }; + const sortedObj = Sort.sortKeys(unsortedObj); + + // Assert that it's not sorted in-place + expect(sortedObj).not.toBe(unsortedObj); + + expect(Object.keys(unsortedObj)).toEqual(['q', 'p', 'r']); + expect(Object.keys(sortedObj)).toEqual(['p', 'q', 'r']); + }); + test('Simple array with objects', () => { + const unsortedArr = [ + { b: 1, a: 0 }, + { y: 0, z: 1, x: 2 } + ]; + const sortedArr = Sort.sortKeys(unsortedArr); + + // Assert that it's not sorted in-place + expect(sortedArr).not.toBe(unsortedArr); + + expect(Object.keys(unsortedArr[0])).toEqual(['b', 'a']); + expect(Object.keys(sortedArr[0])).toEqual(['a', 'b']); + + expect(Object.keys(unsortedArr[1])).toEqual(['y', 'z', 'x']); + expect(Object.keys(sortedArr[1])).toEqual(['x', 'y', 'z']); + }); + test('Nested objects', () => { + const unsortedDeepObj = { c: { q: 0, r: { a: 42 }, p: 2 }, b: { y: 0, z: 1, x: 2 }, a: 2 }; + const sortedDeepObj = Sort.sortKeys(unsortedDeepObj); + + expect(sortedDeepObj).not.toBe(unsortedDeepObj); + + expect(Object.keys(unsortedDeepObj)).toEqual(['c', 'b', 'a']); + expect(Object.keys(sortedDeepObj)).toEqual(['a', 'b', 'c']); + + expect(Object.keys(unsortedDeepObj.b)).toEqual(['y', 'z', 'x']); + expect(Object.keys(sortedDeepObj.b)).toEqual(['x', 'y', 'z']); + + expect(Object.keys(unsortedDeepObj.c)).toEqual(['q', 'r', 'p']); + expect(Object.keys(sortedDeepObj.c)).toEqual(['p', 'q', 'r']); + + expect(Object.keys(unsortedDeepObj.c.r)).toEqual(['a']); + expect(Object.keys(sortedDeepObj.c.r)).toEqual(['a']); + }); +}); diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index bdcb2615f4d..3ce4aa5f65c 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -10,8 +10,10 @@ import { AlreadyReportedError, Async, type IDependenciesMetaTable, - Path + Path, + Sort } from '@rushstack/node-core-library'; +import { createHash } from 'crypto'; import { BaseInstallManager } from '../base/BaseInstallManager'; import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; @@ -378,6 +380,18 @@ export class WorkspaceInstallManager extends BaseInstallManager { shrinkwrapIsUpToDate = false; } + // Check if packageExtensionsChecksum matches globalPackageExtension's hash + const packageExtensionsChecksum: string | undefined = this._getPackageExtensionChecksum( + this.rushConfiguration.pnpmOptions.globalPackageExtensions + ); + const packageExtensionsChecksumAreEqual: boolean = + packageExtensionsChecksum === shrinkwrapFile?.packageExtensionsChecksum; + + if (!packageExtensionsChecksumAreEqual) { + shrinkwrapWarnings.push("The package extension hash doesn't match the current shrinkwrap."); + shrinkwrapIsUpToDate = false; + } + // Write the common package.json InstallHelpers.generateCommonPackageJson(this.rushConfiguration, subspace, undefined); @@ -388,6 +402,18 @@ export class WorkspaceInstallManager extends BaseInstallManager { return { shrinkwrapIsUpToDate, shrinkwrapWarnings }; } + private _getPackageExtensionChecksum( + packageExtensions: Record | undefined + ): string | undefined { + // https://github.com/pnpm/pnpm/blob/ba9409ffcef0c36dc1b167d770a023c87444822d/pkg-manager/core/src/install/index.ts#L331 + const packageExtensionsChecksum: string | undefined = + Object.keys(packageExtensions ?? {}).length === 0 + ? undefined + : createObjectChecksum(packageExtensions!); + + return packageExtensionsChecksum; + } + protected canSkipInstall(lastModifiedDate: Date, subspace: Subspace): boolean { if (!super.canSkipInstall(lastModifiedDate, subspace)) { return false; @@ -744,3 +770,13 @@ export class WorkspaceInstallManager extends BaseInstallManager { } } } + +/** + * Source: https://github.com/pnpm/pnpm/blob/ba9409ffcef0c36dc1b167d770a023c87444822d/pkg-manager/core/src/install/index.ts#L821-L824 + * @param obj + * @returns + */ +function createObjectChecksum(obj: Record): string { + const s: string = JSON.stringify(Sort.sortKeys(obj)); + return createHash('md5').update(s).digest('hex'); +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts index 52fbd234f81..3860200b737 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts @@ -148,6 +148,8 @@ export interface IPnpmShrinkwrapYaml { specifiers: Record; /** The list of override version number for dependencies */ overrides?: { [dependency: string]: string }; + /** The checksum of package extensions fields for extending dependencies */ + packageExtensionsChecksum?: string; } export interface ILoadFromFileOptions { @@ -275,6 +277,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { public readonly specifiers: ReadonlyMap; public readonly packages: ReadonlyMap; public readonly overrides: ReadonlyMap; + public readonly packageExtensionsChecksum: undefined | string; private readonly _shrinkwrapJson: IPnpmShrinkwrapYaml; private readonly _integrities: Map>; @@ -304,6 +307,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { this.specifiers = new Map(Object.entries(shrinkwrapJson.specifiers || {})); this.packages = new Map(Object.entries(shrinkwrapJson.packages || {})); this.overrides = new Map(Object.entries(shrinkwrapJson.overrides || {})); + this.packageExtensionsChecksum = shrinkwrapJson.packageExtensionsChecksum; // Importers only exist in workspaces this.isWorkspaceCompatible = this.importers.size > 0;