From 3e4fc31e2fa8f40ccad97541da96816fe850d42a Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Wed, 10 Sep 2025 18:13:46 -0400 Subject: [PATCH 01/22] Fix concurrency bug for max weighted operation scheduling --- libraries/node-core-library/src/Async.ts | 21 +++- .../node-core-library/src/test/Async.test.ts | 109 ++++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 85d0b0fcdb0..39940c7a806 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -201,6 +201,7 @@ export class Async { let arrayIndex: number = 0; let iteratorIsComplete: boolean = false; let promiseHasResolvedOrRejected: boolean = false; + let nextIterator: IteratorResult | undefined = undefined; async function queueOperationsAsync(): Promise { while ( @@ -213,7 +214,7 @@ export class Async { // there will be effectively no cap on the number of operations waiting. const limitedConcurrency: number = !Number.isFinite(concurrency) ? 1 : concurrency; concurrentUnitsInProgress += limitedConcurrency; - const currentIteratorResult: IteratorResult = await iterator.next(); + const currentIteratorResult: IteratorResult = nextIterator || (await iterator.next()); // eslint-disable-next-line require-atomic-updates iteratorIsComplete = !!currentIteratorResult.done; @@ -225,9 +226,25 @@ export class Async { // Remove the "lock" from the concurrency check and only apply the current weight. // This should allow other operations to execute. - concurrentUnitsInProgress += weight; concurrentUnitsInProgress -= limitedConcurrency; + // Wait until there's have enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` + const weightWithPeekedIsOverConcurrency: boolean = + concurrentUnitsInProgress + weight > concurrency; + const currentUnitsIsZero: boolean = concurrentUnitsInProgress === 0; + const taskWeightIsZero: boolean = weight === 0; + if (weightWithPeekedIsOverConcurrency && !currentUnitsIsZero && !taskWeightIsZero) { + // eslint-disable-next-line require-atomic-updates + nextIterator = currentIteratorResult; + break; + } else { + // clear iterator + // eslint-disable-next-line require-atomic-updates + nextIterator = undefined; + } + + concurrentUnitsInProgress += weight; + Promise.resolve(callback(currentIteratorValue.element, arrayIndex++)) .then(async () => { // Remove the operation completely from the in progress units. diff --git a/libraries/node-core-library/src/test/Async.test.ts b/libraries/node-core-library/src/test/Async.test.ts index 5442723e8b2..64c32fb8097 100644 --- a/libraries/node-core-library/src/test/Async.test.ts +++ b/libraries/node-core-library/src/test/Async.test.ts @@ -27,13 +27,6 @@ describe(Async.name, () => { expect(fn).toHaveBeenNthCalledWith(3, 3, 2); }); - it('returns the same result as built-in Promise.all', async () => { - const array: number[] = [1, 2, 3, 4, 5, 6, 7, 8]; - const fn: (item: number) => Promise = async (item) => `result ${item}`; - - expect(await Async.mapAsync(array, fn)).toEqual(await Promise.all(array.map(fn))); - }); - it('if concurrency is set, ensures no more than N operations occur in parallel', async () => { let running: number = 0; let maxRunning: number = 0; @@ -447,7 +440,7 @@ describe(Async.name, () => { expect(maxRunning).toEqual(3); }); - it('waits for a large operation to finish before scheduling more', async () => { + it.only('waits for a small and large operation to finish before scheduling more', async () => { let running: number = 0; let maxRunning: number = 0; @@ -471,7 +464,7 @@ describe(Async.name, () => { await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true }); expect(fn).toHaveBeenCalledTimes(8); - expect(maxRunning).toEqual(2); + expect(maxRunning).toEqual(1); }); it('allows operations with a weight of 0 and schedules them accordingly', async () => { @@ -496,6 +489,104 @@ describe(Async.name, () => { expect(maxRunning).toEqual(9); }); + it('ensures isolated job runs in isolation while small jobs never run alongside it', async () => { + const maxConcurrency: number = 10; + let running: number = 0; + const jobToMaxConcurrentJobsRunning: Record = {}; + + const array: INumberWithWeight[] = [ + { n: 1, weight: 1 }, + { n: 2, weight: 1 }, + { n: 3, weight: 1 }, + { n: 4, weight: maxConcurrency }, + { n: 5, weight: 1 }, + { n: 6, weight: 1 }, + { n: 7, weight: 1 } + ]; + + const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { + running++; + jobToMaxConcurrentJobsRunning[item.n] = Math.max(jobToMaxConcurrentJobsRunning[item.n] || 0, running); + + // Simulate longer running time for heavyweight job + if (item.weight === maxConcurrency) { + await Async.sleepAsync(50); + } else { + await Async.sleepAsync(10); + } + + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency: maxConcurrency, weighted: true }); + + expect(fn).toHaveBeenCalledTimes(7); + + // The heavyweight job (n=4) should run with only 1 concurrent job (itself) + expect(jobToMaxConcurrentJobsRunning[4]).toEqual(1); + + // Small jobs should be able to run concurrently with each other but not with heavyweight job + const nonIsolatedJobs = array.filter((job) => job.weight !== maxConcurrency); + nonIsolatedJobs.forEach((job) => { + expect(jobToMaxConcurrentJobsRunning[job.n]).toBeGreaterThanOrEqual(1); + expect(jobToMaxConcurrentJobsRunning[job.n]).toBeLessThanOrEqual(6); // All small jobs could theoretically run together + }); + }); + + it('allows zero weight tasks to run alongside weight = concurrency task', async () => { + const concurrency = 3; + const array: INumberWithWeight[] = [ + { n: 1, weight: 0 }, + { n: 2, weight: concurrency }, + { n: 3, weight: 0 } + ]; + + let running: number = 0; + const jobToMaxConcurrentJobsRunning: Record = {}; + + const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { + running++; + jobToMaxConcurrentJobsRunning[item.n] = Math.max(jobToMaxConcurrentJobsRunning[item.n] || 0, running); + + await Async.sleepAsync(0); + + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency: concurrency, weighted: true }); + + expect(jobToMaxConcurrentJobsRunning[1]).toEqual(1); // runs 0 weight + expect(jobToMaxConcurrentJobsRunning[2]).toEqual(2); // runs 0 weight + 3 weight + expect(jobToMaxConcurrentJobsRunning[3]).toEqual(2); // runs 0 weight + 3 weight + }); + + it('allows zero weight tasks to run alongside weight > concurrency task', async () => { + const concurrency = 3; + const array: INumberWithWeight[] = [ + { n: 1, weight: 0 }, + { n: 2, weight: concurrency + 1 }, + { n: 3, weight: 0 } + ]; + + let running: number = 0; + const jobToMaxConcurrentJobsRunning: Record = {}; + + const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { + running++; + jobToMaxConcurrentJobsRunning[item.n] = Math.max(jobToMaxConcurrentJobsRunning[item.n] || 0, running); + + await Async.sleepAsync(0); + + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency, weighted: true }); + + expect(jobToMaxConcurrentJobsRunning[1]).toEqual(1); // runs 0 weight + expect(jobToMaxConcurrentJobsRunning[2]).toEqual(2); // runs 0 weight + 4 weight + expect(jobToMaxConcurrentJobsRunning[3]).toEqual(2); // runs 0 weight + 3 weight + }); + it('does not exceed the maxiumum concurrency for an async iterator when weighted', async () => { let waitingIterators: number = 0; From 534076ed8f5af04ce6ecf5b6a1f9f2d088dea0bf Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Wed, 10 Sep 2025 19:12:43 -0400 Subject: [PATCH 02/22] create Peekable iterator --- libraries/node-core-library/src/Async.ts | 60 ++++++++++++++++-------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 39940c7a806..c46e6343bae 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -70,6 +70,31 @@ export interface IRunWithTimeoutOptions { timeoutMessage?: string; } +class PeekableIterator { + private _peekedResult: IteratorResult | undefined = undefined; + private readonly _iterator: Iterator | AsyncIterator; + + public constructor(iterator: Iterator | AsyncIterator) { + this._iterator = iterator; + } + + public async peekAsync(): Promise> { + if (this._peekedResult === undefined) { + this._peekedResult = await this._iterator.next(); + } + return this._peekedResult; + } + + public async nextAsync(): Promise> { + if (this._peekedResult !== undefined) { + const result: IteratorResult = this._peekedResult; + this._peekedResult = undefined; + return result; + } + return await this._iterator.next(); + } +} + /** * @remarks * Used with {@link (Async:class).(forEachAsync:2)} and {@link (Async:class).(mapAsync:2)}. @@ -194,14 +219,14 @@ export class Async { options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity; let concurrentUnitsInProgress: number = 0; - const iterator: Iterator | AsyncIterator = (iterable as AsyncIterable)[ + const baseIterator: Iterator | AsyncIterator = (iterable as AsyncIterable)[ Symbol.asyncIterator ].call(iterable); + const iterator: PeekableIterator = new PeekableIterator(baseIterator); let arrayIndex: number = 0; let iteratorIsComplete: boolean = false; let promiseHasResolvedOrRejected: boolean = false; - let nextIterator: IteratorResult | undefined = undefined; async function queueOperationsAsync(): Promise { while ( @@ -214,34 +239,31 @@ export class Async { // there will be effectively no cap on the number of operations waiting. const limitedConcurrency: number = !Number.isFinite(concurrency) ? 1 : concurrency; concurrentUnitsInProgress += limitedConcurrency; - const currentIteratorResult: IteratorResult = nextIterator || (await iterator.next()); + + // Peek at the next item to check its weight before committing to process it + const peekedIteratorResult: IteratorResult = await iterator.peekAsync(); // eslint-disable-next-line require-atomic-updates - iteratorIsComplete = !!currentIteratorResult.done; + iteratorIsComplete = !!peekedIteratorResult.done; if (!iteratorIsComplete) { - const currentIteratorValue: TEntry = currentIteratorResult.value; - Async.validateWeightedIterable(currentIteratorValue); + const peekedIteratorValue: TEntry = peekedIteratorResult.value; + Async.validateWeightedIterable(peekedIteratorValue); // Cap the weight to concurrency, this allows 0 weight items to execute despite the concurrency limit. - const weight: number = Math.min(currentIteratorValue.weight, concurrency); + const weight: number = Math.min(peekedIteratorValue.weight, concurrency); // Remove the "lock" from the concurrency check and only apply the current weight. // This should allow other operations to execute. concurrentUnitsInProgress -= limitedConcurrency; - // Wait until there's have enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` - const weightWithPeekedIsOverConcurrency: boolean = - concurrentUnitsInProgress + weight > concurrency; - const currentUnitsIsZero: boolean = concurrentUnitsInProgress === 0; - const taskWeightIsZero: boolean = weight === 0; - if (weightWithPeekedIsOverConcurrency && !currentUnitsIsZero && !taskWeightIsZero) { - // eslint-disable-next-line require-atomic-updates - nextIterator = currentIteratorResult; + // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` + const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; + const hasRunningTasks: boolean = concurrentUnitsInProgress > 0; + const isWeightedTask: boolean = weight > 0; + if (wouldExceedConcurrency && hasRunningTasks && isWeightedTask) { break; - } else { - // clear iterator - // eslint-disable-next-line require-atomic-updates - nextIterator = undefined; } + const currentIteratorResult: IteratorResult = await iterator.nextAsync(); + const currentIteratorValue: TEntry = currentIteratorResult.value; concurrentUnitsInProgress += weight; From ba1c69406964cf477ab06d2d7fab964f76c9d49d Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Thu, 11 Sep 2025 11:24:45 -0400 Subject: [PATCH 03/22] changelog --- .../eb-concurrency-bug-fix_2025-09-11-15-24.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json diff --git a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json new file mode 100644 index 00000000000..837b44a1abe --- /dev/null +++ b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "fix weighted concurrency scheduling to prevent oversubscription", + "type": "patch" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file From c50ea4f56bd1fd1132caff7eebc0281898986793 Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Thu, 11 Sep 2025 18:40:55 -0400 Subject: [PATCH 04/22] go back to singleton next iterator --- libraries/node-core-library/src/Async.ts | 49 +++++-------------- .../node-core-library/src/test/Async.test.ts | 6 +-- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index c46e6343bae..30315dcdc69 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -70,31 +70,6 @@ export interface IRunWithTimeoutOptions { timeoutMessage?: string; } -class PeekableIterator { - private _peekedResult: IteratorResult | undefined = undefined; - private readonly _iterator: Iterator | AsyncIterator; - - public constructor(iterator: Iterator | AsyncIterator) { - this._iterator = iterator; - } - - public async peekAsync(): Promise> { - if (this._peekedResult === undefined) { - this._peekedResult = await this._iterator.next(); - } - return this._peekedResult; - } - - public async nextAsync(): Promise> { - if (this._peekedResult !== undefined) { - const result: IteratorResult = this._peekedResult; - this._peekedResult = undefined; - return result; - } - return await this._iterator.next(); - } -} - /** * @remarks * Used with {@link (Async:class).(forEachAsync:2)} and {@link (Async:class).(mapAsync:2)}. @@ -219,14 +194,15 @@ export class Async { options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity; let concurrentUnitsInProgress: number = 0; - const baseIterator: Iterator | AsyncIterator = (iterable as AsyncIterable)[ + const iterator: Iterator | AsyncIterator = (iterable as AsyncIterable)[ Symbol.asyncIterator ].call(iterable); - const iterator: PeekableIterator = new PeekableIterator(baseIterator); let arrayIndex: number = 0; let iteratorIsComplete: boolean = false; let promiseHasResolvedOrRejected: boolean = false; + // iterator that is stored when the loop exits early due to not enough concurrency + let nextIterator: IteratorResult | undefined = undefined; async function queueOperationsAsync(): Promise { while ( @@ -239,17 +215,15 @@ export class Async { // there will be effectively no cap on the number of operations waiting. const limitedConcurrency: number = !Number.isFinite(concurrency) ? 1 : concurrency; concurrentUnitsInProgress += limitedConcurrency; - - // Peek at the next item to check its weight before committing to process it - const peekedIteratorResult: IteratorResult = await iterator.peekAsync(); + const currentIteratorResult: IteratorResult = nextIterator || (await iterator.next()); // eslint-disable-next-line require-atomic-updates - iteratorIsComplete = !!peekedIteratorResult.done; + iteratorIsComplete = !!currentIteratorResult.done; if (!iteratorIsComplete) { - const peekedIteratorValue: TEntry = peekedIteratorResult.value; - Async.validateWeightedIterable(peekedIteratorValue); + const currentIteratorValue: TEntry = currentIteratorResult.value; + Async.validateWeightedIterable(currentIteratorValue); // Cap the weight to concurrency, this allows 0 weight items to execute despite the concurrency limit. - const weight: number = Math.min(peekedIteratorValue.weight, concurrency); + const weight: number = Math.min(currentIteratorValue.weight, concurrency); // Remove the "lock" from the concurrency check and only apply the current weight. // This should allow other operations to execute. @@ -260,10 +234,13 @@ export class Async { const hasRunningTasks: boolean = concurrentUnitsInProgress > 0; const isWeightedTask: boolean = weight > 0; if (wouldExceedConcurrency && hasRunningTasks && isWeightedTask) { + // eslint-disable-next-line require-atomic-updates + nextIterator = currentIteratorResult; break; + } else { + // eslint-disable-next-line require-atomic-updates + nextIterator = undefined; } - const currentIteratorResult: IteratorResult = await iterator.nextAsync(); - const currentIteratorValue: TEntry = currentIteratorResult.value; concurrentUnitsInProgress += weight; diff --git a/libraries/node-core-library/src/test/Async.test.ts b/libraries/node-core-library/src/test/Async.test.ts index 64c32fb8097..932d1b20d61 100644 --- a/libraries/node-core-library/src/test/Async.test.ts +++ b/libraries/node-core-library/src/test/Async.test.ts @@ -440,7 +440,7 @@ describe(Async.name, () => { expect(maxRunning).toEqual(3); }); - it.only('waits for a small and large operation to finish before scheduling more', async () => { + it('waits for a small and large operation to finish before scheduling more', async () => { let running: number = 0; let maxRunning: number = 0; @@ -557,7 +557,7 @@ describe(Async.name, () => { expect(jobToMaxConcurrentJobsRunning[1]).toEqual(1); // runs 0 weight expect(jobToMaxConcurrentJobsRunning[2]).toEqual(2); // runs 0 weight + 3 weight - expect(jobToMaxConcurrentJobsRunning[3]).toEqual(2); // runs 0 weight + 3 weight + expect(jobToMaxConcurrentJobsRunning[3]).toEqual(1); // runs 0 weight after 3 weight completes }); it('allows zero weight tasks to run alongside weight > concurrency task', async () => { @@ -584,7 +584,7 @@ describe(Async.name, () => { expect(jobToMaxConcurrentJobsRunning[1]).toEqual(1); // runs 0 weight expect(jobToMaxConcurrentJobsRunning[2]).toEqual(2); // runs 0 weight + 4 weight - expect(jobToMaxConcurrentJobsRunning[3]).toEqual(2); // runs 0 weight + 3 weight + expect(jobToMaxConcurrentJobsRunning[3]).toEqual(1); // runs 0 weight after 3 weight completes }); it('does not exceed the maxiumum concurrency for an async iterator when weighted', async () => { From 5a840652f97784ca8d7e1687617faab01c3235ba Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Fri, 12 Sep 2025 18:55:41 -0400 Subject: [PATCH 05/22] reviews --- .../eb-concurrency-bug-fix_2025-09-11-15-24.json | 2 +- libraries/node-core-library/src/Async.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json index 837b44a1abe..0db1a802bc1 100644 --- a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json +++ b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "fix weighted concurrency scheduling to prevent oversubscription", + "comment": "Fix Async.forEachAsync weighted concurrency scheduling to prevent oversubscription", "type": "patch" } ], diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 30315dcdc69..0c32df1c029 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -232,16 +232,14 @@ export class Async { // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; const hasRunningTasks: boolean = concurrentUnitsInProgress > 0; - const isWeightedTask: boolean = weight > 0; - if (wouldExceedConcurrency && hasRunningTasks && isWeightedTask) { + if (wouldExceedConcurrency && hasRunningTasks) { // eslint-disable-next-line require-atomic-updates nextIterator = currentIteratorResult; break; - } else { - // eslint-disable-next-line require-atomic-updates - nextIterator = undefined; } + // eslint-disable-next-line require-atomic-updates + nextIterator = undefined; concurrentUnitsInProgress += weight; Promise.resolve(callback(currentIteratorValue.element, arrayIndex++)) From 7688a325b53c79a667c7db42d2da76a668aa0c7f Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Tue, 16 Sep 2025 15:06:41 -0400 Subject: [PATCH 06/22] reviews --- libraries/node-core-library/src/Async.ts | 31 +++- .../node-core-library/src/test/Async.test.ts | 168 +++++++----------- libraries/operation-graph/src/Operation.ts | 19 +- .../src/api/RushProjectConfiguration.ts | 7 + .../src/logic/operations/Operation.ts | 7 + .../operations/WeightedOperationPlugin.ts | 5 +- .../src/schemas/rush-project.schema.json | 4 + 7 files changed, 127 insertions(+), 114 deletions(-) diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 0c32df1c029..9982103a302 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -14,7 +14,8 @@ export interface IAsyncParallelismOptions { /** * Optionally used with the {@link (Async:class).(mapAsync:1)}, {@link (Async:class).(mapAsync:2)} and * {@link (Async:class).(forEachAsync:1)}, and {@link (Async:class).(forEachAsync:2)} to limit the maximum - * number of concurrent promises to the specified number. + * number of concurrent promises to the specified number. Individual operations may exceed this + * limit based on their `allowOversubscription` property. */ concurrency?: number; @@ -82,12 +83,25 @@ export interface IWeighted { * Must be a whole number greater than or equal to 0. */ weight: number; + + /** + * Controls whether this operation can start even if doing so would exceed the total concurrency limit. + * If true (default), will start the operation even when it would exceed the limit. + * If false, waits until sufficient capacity is available. + */ + allowOversubscription?: boolean; +} + +interface IWeightedWrapper { + allowOversubscription?: boolean; + element: TElement; + weight: number; } function toWeightedIterator( iterable: Iterable | AsyncIterable, useWeights?: boolean -): AsyncIterable<{ element: TEntry; weight: number }> { +): AsyncIterable> { const iterator: Iterator | AsyncIterator = ( (iterable as Iterable)[Symbol.iterator] || (iterable as AsyncIterable)[Symbol.asyncIterator] @@ -99,7 +113,11 @@ function toWeightedIterator( // The await is necessary here, but TS will complain - it's a false positive. const { value, done } = await iterator.next(); return { - value: { element: value, weight: useWeights ? value?.weight : 1 }, + value: { + allowOversubscription: value?.allowOversubscription ?? true, + element: value, + weight: useWeights ? value?.weight : 1 + }, done: !!done }; } @@ -184,7 +202,7 @@ export class Async { return result; } - private static async _forEachWeightedAsync( + private static async _forEachWeightedAsync>( iterable: AsyncIterable, callback: (entry: TReturn, arrayIndex: number) => Promise, options?: IAsyncParallelismOptions | undefined @@ -231,8 +249,8 @@ export class Async { // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; - const hasRunningTasks: boolean = concurrentUnitsInProgress > 0; - if (wouldExceedConcurrency && hasRunningTasks) { + const allowOversubscription: boolean = currentIteratorValue.allowOversubscription ?? true; + if (!allowOversubscription && wouldExceedConcurrency) { // eslint-disable-next-line require-atomic-updates nextIterator = currentIteratorResult; break; @@ -320,6 +338,7 @@ export class Async { * number of concurrency units that can be in progress at once. The weight of each operation * determines how many concurrency units it takes up. For example, if the concurrency is 2 * and the first operation has a weight of 2, then only one more operation can be in progress. + * Operations may exceed the concurrency limit based on their `allowOversubscription` property. * * If `callback` throws a synchronous exception, or if it returns a promise that rejects, * then the loop stops immediately. Any remaining array items will be skipped, and diff --git a/libraries/node-core-library/src/test/Async.test.ts b/libraries/node-core-library/src/test/Async.test.ts index 932d1b20d61..a1ece4786c1 100644 --- a/libraries/node-core-library/src/test/Async.test.ts +++ b/libraries/node-core-library/src/test/Async.test.ts @@ -3,6 +3,12 @@ import { Async, AsyncQueue } from '../Async'; +interface INumberWithWeight { + allowOversubscription?: boolean; + n: number; + weight: number; +} + describe(Async.name, () => { describe(Async.mapAsync.name, () => { it('handles an empty array correctly', async () => { @@ -307,11 +313,6 @@ describe(Async.name, () => { ).rejects.toThrow(expectedError); }); - interface INumberWithWeight { - n: number; - weight: number; - } - it('handles an empty array correctly', async () => { let running: number = 0; let maxRunning: number = 0; @@ -440,7 +441,7 @@ describe(Async.name, () => { expect(maxRunning).toEqual(3); }); - it('waits for a small and large operation to finish before scheduling more', async () => { + it('waits for a large operation to finish before scheduling more', async () => { let running: number = 0; let maxRunning: number = 0; @@ -464,7 +465,7 @@ describe(Async.name, () => { await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true }); expect(fn).toHaveBeenCalledTimes(8); - expect(maxRunning).toEqual(1); + expect(maxRunning).toEqual(2); }); it('allows operations with a weight of 0 and schedules them accordingly', async () => { @@ -489,104 +490,6 @@ describe(Async.name, () => { expect(maxRunning).toEqual(9); }); - it('ensures isolated job runs in isolation while small jobs never run alongside it', async () => { - const maxConcurrency: number = 10; - let running: number = 0; - const jobToMaxConcurrentJobsRunning: Record = {}; - - const array: INumberWithWeight[] = [ - { n: 1, weight: 1 }, - { n: 2, weight: 1 }, - { n: 3, weight: 1 }, - { n: 4, weight: maxConcurrency }, - { n: 5, weight: 1 }, - { n: 6, weight: 1 }, - { n: 7, weight: 1 } - ]; - - const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { - running++; - jobToMaxConcurrentJobsRunning[item.n] = Math.max(jobToMaxConcurrentJobsRunning[item.n] || 0, running); - - // Simulate longer running time for heavyweight job - if (item.weight === maxConcurrency) { - await Async.sleepAsync(50); - } else { - await Async.sleepAsync(10); - } - - running--; - }); - - await Async.forEachAsync(array, fn, { concurrency: maxConcurrency, weighted: true }); - - expect(fn).toHaveBeenCalledTimes(7); - - // The heavyweight job (n=4) should run with only 1 concurrent job (itself) - expect(jobToMaxConcurrentJobsRunning[4]).toEqual(1); - - // Small jobs should be able to run concurrently with each other but not with heavyweight job - const nonIsolatedJobs = array.filter((job) => job.weight !== maxConcurrency); - nonIsolatedJobs.forEach((job) => { - expect(jobToMaxConcurrentJobsRunning[job.n]).toBeGreaterThanOrEqual(1); - expect(jobToMaxConcurrentJobsRunning[job.n]).toBeLessThanOrEqual(6); // All small jobs could theoretically run together - }); - }); - - it('allows zero weight tasks to run alongside weight = concurrency task', async () => { - const concurrency = 3; - const array: INumberWithWeight[] = [ - { n: 1, weight: 0 }, - { n: 2, weight: concurrency }, - { n: 3, weight: 0 } - ]; - - let running: number = 0; - const jobToMaxConcurrentJobsRunning: Record = {}; - - const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { - running++; - jobToMaxConcurrentJobsRunning[item.n] = Math.max(jobToMaxConcurrentJobsRunning[item.n] || 0, running); - - await Async.sleepAsync(0); - - running--; - }); - - await Async.forEachAsync(array, fn, { concurrency: concurrency, weighted: true }); - - expect(jobToMaxConcurrentJobsRunning[1]).toEqual(1); // runs 0 weight - expect(jobToMaxConcurrentJobsRunning[2]).toEqual(2); // runs 0 weight + 3 weight - expect(jobToMaxConcurrentJobsRunning[3]).toEqual(1); // runs 0 weight after 3 weight completes - }); - - it('allows zero weight tasks to run alongside weight > concurrency task', async () => { - const concurrency = 3; - const array: INumberWithWeight[] = [ - { n: 1, weight: 0 }, - { n: 2, weight: concurrency + 1 }, - { n: 3, weight: 0 } - ]; - - let running: number = 0; - const jobToMaxConcurrentJobsRunning: Record = {}; - - const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { - running++; - jobToMaxConcurrentJobsRunning[item.n] = Math.max(jobToMaxConcurrentJobsRunning[item.n] || 0, running); - - await Async.sleepAsync(0); - - running--; - }); - - await Async.forEachAsync(array, fn, { concurrency, weighted: true }); - - expect(jobToMaxConcurrentJobsRunning[1]).toEqual(1); // runs 0 weight - expect(jobToMaxConcurrentJobsRunning[2]).toEqual(2); // runs 0 weight + 4 weight - expect(jobToMaxConcurrentJobsRunning[3]).toEqual(1); // runs 0 weight after 3 weight completes - }); - it('does not exceed the maxiumum concurrency for an async iterator when weighted', async () => { let waitingIterators: number = 0; @@ -779,6 +682,61 @@ describe(Async.name, () => { expect(sleepSpy).toHaveBeenCalledTimes(1); expect(sleepSpy).toHaveBeenLastCalledWith(5); }); + + describe('allowOversubscription=false operations', () => { + it('waits for a small and large operation to finish before scheduling more', async () => { + let running: number = 0; + let maxRunning: number = 0; + + const array: INumberWithWeight[] = [ + { n: 1, weight: 1, allowOversubscription: false }, + { n: 2, weight: 10, allowOversubscription: false }, + { n: 3, weight: 1, allowOversubscription: false }, + { n: 4, weight: 10, allowOversubscription: false }, + { n: 5, weight: 1, allowOversubscription: false }, + { n: 6, weight: 10, allowOversubscription: false }, + { n: 7, weight: 1, allowOversubscription: false }, + { n: 8, weight: 10, allowOversubscription: false } + ]; + + const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { + running++; + await Async.sleepAsync(0); + maxRunning = Math.max(maxRunning, running); + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true }); + expect(fn).toHaveBeenCalledTimes(8); + expect(maxRunning).toEqual(1); + }); + + it('handles operations where some have undefined and others have explicit values', async () => { + const concurrency = 3; + let running: number = 0; + let maxRunning: number = 0; + const array: INumberWithWeight[] = [ + { n: 1, weight: 3 }, // undefined allowOversubscription (should default to true) + { n: 2, weight: 3, allowOversubscription: false }, + { n: 3, weight: 1 }, // undefined allowOversubscription (should default to true) + { n: 4, weight: 1, allowOversubscription: true } + ]; + + const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { + running++; + maxRunning = Math.max(maxRunning, running); + + await Async.sleepAsync(0); + + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency, weighted: true }); + + expect(fn).toHaveBeenCalledTimes(4); + expect(maxRunning).toBeLessThanOrEqual(4); // Respects weight and concurrency limits + }); + }); }); }); diff --git a/libraries/operation-graph/src/Operation.ts b/libraries/operation-graph/src/Operation.ts index 75528ce1295..527c8aaaf04 100644 --- a/libraries/operation-graph/src/Operation.ts +++ b/libraries/operation-graph/src/Operation.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError } from '@rushstack/node-core-library'; +import { InternalError, type IWeighted } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { Stopwatch } from './Stopwatch'; @@ -41,6 +41,13 @@ export interface IOperationOptions - implements IOperationStates + implements IOperationStates, IWeighted { /** * A set of all dependencies which must be executed before this operation is complete. @@ -174,6 +181,13 @@ export class Operation Date: Tue, 16 Sep 2025 15:06:52 -0400 Subject: [PATCH 07/22] update changelogs --- .../rush/eb-concurrency-bug-fix_2025-09-16-19-06.json | 10 ++++++++++ .../eb-concurrency-bug-fix_2025-09-11-15-24.json | 4 ++-- .../eb-concurrency-bug-fix_2025-09-16-19-06.json | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json create mode 100644 common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json diff --git a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json new file mode 100644 index 00000000000..d0bf85feb5f --- /dev/null +++ b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "add allowOversubscription option to prevent running tasks from exceeding concurrency", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json index 0db1a802bc1..9b543642f9e 100644 --- a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json +++ b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json @@ -2,8 +2,8 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Fix Async.forEachAsync weighted concurrency scheduling to prevent oversubscription", - "type": "patch" + "comment": "Add allowOversubscription option to prevent running tasks from exceeding concurrency", + "type": "minor" } ], "packageName": "@rushstack/node-core-library" diff --git a/common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json new file mode 100644 index 00000000000..809d1e7764e --- /dev/null +++ b/common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/operation-graph", + "comment": "add allowOversubscription option to prevent running tasks from exceeding concurrency", + "type": "minor" + } + ], + "packageName": "@rushstack/operation-graph" +} \ No newline at end of file From 381b71864c5b6898bee028020bdc4c76934e183b Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Tue, 16 Sep 2025 15:13:20 -0400 Subject: [PATCH 08/22] api reviews --- common/reviews/api/node-core-library.api.md | 1 + common/reviews/api/operation-graph.api.md | 5 ++++- common/reviews/api/rush-lib.api.md | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index a0fc83dccdc..76160b35239 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -669,6 +669,7 @@ export interface IWaitForExitWithStringOptions extends IWaitForExitOptions { // @public (undocumented) export interface IWeighted { + allowOversubscription?: boolean; weight: number; } diff --git a/common/reviews/api/operation-graph.api.md b/common/reviews/api/operation-graph.api.md index eac2ff25c29..7de4ee103ac 100644 --- a/common/reviews/api/operation-graph.api.md +++ b/common/reviews/api/operation-graph.api.md @@ -7,6 +7,7 @@ /// import type { ITerminal } from '@rushstack/terminal'; +import { IWeighted } from '@rushstack/node-core-library'; // @beta export type CommandMessageFromHost = ICancelCommandMessage | IExitCommandMessage | IRunCommandMessage | ISyncCommandMessage; @@ -65,6 +66,7 @@ export interface IOperationExecutionOptions { + allowOversubscription?: boolean | undefined; group?: OperationGroupRecord | undefined; metadata?: TMetadata | undefined; name: string; @@ -148,10 +150,11 @@ export interface IWatchLoopState { } // @beta -export class Operation implements IOperationStates { +export class Operation implements IOperationStates, IWeighted { constructor(options: IOperationOptions); // (undocumented) addDependency(dependency: Operation): void; + allowOversubscription: boolean; readonly consumers: Set>; criticalPathLength: number | undefined; // (undocumented) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 50b24af3f22..3562b51724c 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -690,6 +690,7 @@ export interface IOperationRunnerContext { // @alpha (undocumented) export interface IOperationSettings { allowCobuildWithoutCache?: boolean; + allowOversubscription?: boolean; dependsOnAdditionalFiles?: string[]; dependsOnEnvVars?: string[]; disableBuildCacheForOperation?: boolean; @@ -987,6 +988,7 @@ export class NpmOptionsConfiguration extends PackageManagerOptionsConfigurationB export class Operation { constructor(options: IOperationOptions); addDependency(dependency: Operation): void; + allowOversubscription: boolean; readonly associatedPhase: IPhase; readonly associatedProject: RushConfigurationProject; readonly consumers: ReadonlySet; From 18960cdf75a724161bd4da917ef7fc099b7cad83 Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Tue, 16 Sep 2025 18:11:50 -0400 Subject: [PATCH 09/22] test cleanup before review --- ...-concurrency-bug-fix_2025-09-16-19-06.json | 2 +- libraries/node-core-library/src/Async.ts | 2 +- .../node-core-library/src/test/Async.test.ts | 130 ++++++++++++++++-- libraries/operation-graph/src/Operation.ts | 2 +- .../src/api/RushProjectConfiguration.ts | 2 +- .../src/logic/operations/Operation.ts | 2 +- 6 files changed, 123 insertions(+), 17 deletions(-) diff --git a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json index d0bf85feb5f..48dae0db27c 100644 --- a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json +++ b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json @@ -3,7 +3,7 @@ { "packageName": "@microsoft/rush", "comment": "add allowOversubscription option to prevent running tasks from exceeding concurrency", - "type": "none" + "type": "minor" } ], "packageName": "@microsoft/rush" diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 9982103a302..7c9c0c331bd 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -85,7 +85,7 @@ export interface IWeighted { weight: number; /** - * Controls whether this operation can start even if doing so would exceed the total concurrency limit. + * Controls whether this operation can start, even if doing so would exceed the total concurrency limit.. * If true (default), will start the operation even when it would exceed the limit. * If false, waits until sufficient capacity is available. */ diff --git a/libraries/node-core-library/src/test/Async.test.ts b/libraries/node-core-library/src/test/Async.test.ts index a1ece4786c1..c06df289b87 100644 --- a/libraries/node-core-library/src/test/Async.test.ts +++ b/libraries/node-core-library/src/test/Async.test.ts @@ -60,6 +60,31 @@ describe(Async.name, () => { expect(maxRunning).toEqual(3); }); + it('respects concurrency limit with allowOversubscription=false in mapAsync', async () => { + const array: INumberWithWeight[] = [ + { n: 1, weight: 2, allowOversubscription: false }, + { n: 2, weight: 2, allowOversubscription: false } + ]; + + let running = 0; + let maxRunning = 0; + + const result = await Async.mapAsync( + array, + async (item) => { + running++; + maxRunning = Math.max(maxRunning, running); + await Async.sleepAsync(0); + running--; + return `result-${item.n}`; + }, + { concurrency: 3, weighted: true } + ); + + expect(result).toEqual(['result-1', 'result-2']); + expect(maxRunning).toEqual(1); + }); + it('rejects if a sync iterator throws an error', async () => { const expectedError: Error = new Error('iterator error'); let iteratorIndex: number = 0; @@ -536,6 +561,10 @@ describe(Async.name, () => { }); describe(Async.runWithRetriesAsync.name, () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('Correctly handles a sync function that succeeds the first time', async () => { const expectedResult: string = 'RESULT'; const result: string = await Async.runWithRetriesAsync({ action: () => expectedResult, maxRetries: 0 }); @@ -684,6 +713,76 @@ describe(Async.name, () => { }); describe('allowOversubscription=false operations', () => { + it.each([ + { + concurrency: 4, + weight: 4, + expectedConcurrency: 1, + numberOfTasks: 4 + }, + { + concurrency: 4, + weight: 1, + expectedConcurrency: 4, + numberOfTasks: 4 + }, + { + concurrency: 4, + weight: 5, + expectedConcurrency: 1, + numberOfTasks: 2 + } + ])( + 'enforces strict concurrency limits when allowOversubscription=false: concurrency=$concurrency, weight=$weight, expects max $expectedConcurrency concurrent operations', + async ({ concurrency, weight, expectedConcurrency, numberOfTasks }) => { + let running: number = 0; + let maxRunning: number = 0; + + const array: INumberWithWeight[] = Array.from({ length: numberOfTasks }, (v, i) => i).map((n) => ({ + n, + weight, + allowOversubscription: false + })); + + const fn: (item: INumberWithWeight) => Promise = jest.fn(async () => { + running++; + await Async.sleepAsync(0); + maxRunning = Math.max(maxRunning, running); + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency, weighted: true }); + expect(fn).toHaveBeenCalledTimes(numberOfTasks); + expect(maxRunning).toEqual(expectedConcurrency); + } + ); + + it('handles mixed weights enforcing a strict concurrency limit', async () => { + let running = 0; + let maxRunning = 0; + const startOrder: number[] = []; + + const array: INumberWithWeight[] = [ + { n: 1, weight: 1, allowOversubscription: false }, + { n: 2, weight: 3, allowOversubscription: false }, + { n: 3, weight: 1, allowOversubscription: false }, + { n: 4, weight: 2, allowOversubscription: false } + ]; + + const fn = jest.fn(async (item: INumberWithWeight) => { + running++; + startOrder.push(item.n); + maxRunning = Math.max(maxRunning, running); + await Async.sleepAsync(0); + running--; + }); + + await Async.forEachAsync(array, fn, { concurrency: 4, weighted: true }); + + expect(fn).toHaveBeenCalledTimes(4); + expect(maxRunning).toEqual(2); // Max should be limited by weight 3 task + }); + it('waits for a small and large operation to finish before scheduling more', async () => { let running: number = 0; let maxRunning: number = 0; @@ -711,30 +810,37 @@ describe(Async.name, () => { expect(maxRunning).toEqual(1); }); - it('handles operations where some have undefined and others have explicit values', async () => { - const concurrency = 3; + it('handles operations with mixed values of allowOversubscription', async () => { + const concurrency: number = 3; let running: number = 0; let maxRunning: number = 0; + const taskToMaxConcurrency: Record = {}; + const array: INumberWithWeight[] = [ - { n: 1, weight: 3 }, // undefined allowOversubscription (should default to true) - { n: 2, weight: 3, allowOversubscription: false }, - { n: 3, weight: 1 }, // undefined allowOversubscription (should default to true) - { n: 4, weight: 1, allowOversubscription: true } + { n: 1, weight: 1 }, // undefined allowOversubscription (should default to true) + { n: 2, weight: 2 }, // undefined allowOversubscription (should default to true) + { n: 3, weight: concurrency, allowOversubscription: false }, + { n: 4, weight: 1 }, // undefined allowOversubscription (should default to true) + { n: 5, weight: 1, allowOversubscription: true } ]; const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { running++; - maxRunning = Math.max(maxRunning, running); - + taskToMaxConcurrency[item.n] = running; await Async.sleepAsync(0); - + maxRunning = Math.max(maxRunning, running); running--; }); await Async.forEachAsync(array, fn, { concurrency, weighted: true }); - - expect(fn).toHaveBeenCalledTimes(4); - expect(maxRunning).toBeLessThanOrEqual(4); // Respects weight and concurrency limits + expect(fn).toHaveBeenCalledTimes(5); + expect(maxRunning).toEqual(2); + + expect(taskToMaxConcurrency[1]).toEqual(1); // task 1 + 2 + expect(taskToMaxConcurrency[2]).toEqual(2); // task 1 + 2 + expect(taskToMaxConcurrency[3]).toEqual(1); // task 3 (allowOversubscription = false) + expect(taskToMaxConcurrency[4]).toEqual(1); // task 4 + expect(taskToMaxConcurrency[5]).toEqual(2); // task 4 + 5 }); }); }); diff --git a/libraries/operation-graph/src/Operation.ts b/libraries/operation-graph/src/Operation.ts index 527c8aaaf04..f0c1d23833e 100644 --- a/libraries/operation-graph/src/Operation.ts +++ b/libraries/operation-graph/src/Operation.ts @@ -42,7 +42,7 @@ export interface IOperationOptions Date: Tue, 16 Sep 2025 16:44:23 -0700 Subject: [PATCH 10/22] Replace uuid package dependency with Node.js built-in crypto.randomUUID (#5364) * Initial plan * Replace uuid package with Node.js built-in crypto.randomUUID Co-authored-by: bmiddha <5100938+bmiddha@users.noreply.github.com> * Add rush change file for uuid package replacement Co-authored-by: bmiddha <5100938+bmiddha@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bmiddha <5100938+bmiddha@users.noreply.github.com> --- ...-8acb-4963-a955-6ee097ae90bc_2025-09-16-21-58.json | 11 +++++++++++ .../subspaces/build-tests-subspace/repo-state.json | 2 +- common/config/subspaces/default/pnpm-lock.yaml | 10 ---------- common/config/subspaces/default/repo-state.json | 2 +- libraries/rush-lib/package.json | 4 +--- libraries/rush-lib/src/api/CobuildConfiguration.ts | 4 ++-- 6 files changed, 16 insertions(+), 17 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-fix-7b47235e-8acb-4963-a955-6ee097ae90bc_2025-09-16-21-58.json diff --git a/common/changes/@microsoft/rush/copilot-fix-7b47235e-8acb-4963-a955-6ee097ae90bc_2025-09-16-21-58.json b/common/changes/@microsoft/rush/copilot-fix-7b47235e-8acb-4963-a955-6ee097ae90bc_2025-09-16-21-58.json new file mode 100644 index 00000000000..8b220b0dc7a --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-fix-7b47235e-8acb-4963-a955-6ee097ae90bc_2025-09-16-21-58.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Replace uuid package dependency with Node.js built-in crypto.randomUUID", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "198982749+Copilot@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index df8d3168341..c0a266ac12c 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -2,5 +2,5 @@ { "pnpmShrinkwrapHash": "f89693a88037554bf0c35db4f2295ef771cd2a71", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "364e001eac655a92be31ddb4bbf0d8b291d1e9cc" + "packageJsonInjectedDependenciesHash": "2fad9cbc4726f383da294e793c5b891d8775fca6" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index b19882175df..3818f460807 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3668,9 +3668,6 @@ importers: true-case-path: specifier: ~2.2.1 version: 2.2.1 - uuid: - specifier: ~8.3.2 - version: 8.3.2 devDependencies: '@pnpm/lockfile.types': specifier: ~1.0.3 @@ -3720,9 +3717,6 @@ importers: '@types/tar': specifier: 6.1.6 version: 6.1.6 - '@types/uuid': - specifier: ~8.3.4 - version: 8.3.4 '@types/webpack-env': specifier: 1.18.8 version: 1.18.8 @@ -14190,10 +14184,6 @@ packages: resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} dev: false - /@types/uuid@8.3.4: - resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} - dev: true - /@types/vscode@1.103.0: resolution: {integrity: sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q==} dev: true diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 3c852d397ea..f13a9c6c431 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "43e8674ca74b9c3f20cf12f03de5ce2968017331", + "pnpmShrinkwrapHash": "3749a69d1b0594a63c7a5ad6628f2897dbc3247c", "preferredVersionsHash": "61cd419c533464b580f653eb5f5a7e27fe7055ca" } diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index 1c5090d7350..0ce5842b6a6 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -65,8 +65,7 @@ "strict-uri-encode": "~2.0.0", "tapable": "2.2.1", "tar": "~6.2.1", - "true-case-path": "~2.2.1", - "uuid": "~8.3.2" + "true-case-path": "~2.2.1" }, "devDependencies": { "@pnpm/lockfile.types": "~1.0.3", @@ -87,7 +86,6 @@ "@types/ssri": "~7.1.0", "@types/strict-uri-encode": "2.0.0", "@types/tar": "6.1.6", - "@types/uuid": "~8.3.4", "@types/webpack-env": "1.18.8", "webpack": "~5.98.0" }, diff --git a/libraries/rush-lib/src/api/CobuildConfiguration.ts b/libraries/rush-lib/src/api/CobuildConfiguration.ts index 0f169a2966a..8e7da0a6ec6 100644 --- a/libraries/rush-lib/src/api/CobuildConfiguration.ts +++ b/libraries/rush-lib/src/api/CobuildConfiguration.ts @@ -3,7 +3,7 @@ import { FileSystem, JsonFile, JsonSchema } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; -import { v4 as uuidv4 } from 'uuid'; +import { randomUUID } from 'node:crypto'; import { EnvironmentConfiguration } from './EnvironmentConfiguration'; import type { CobuildLockProviderFactory, RushSession } from '../pluginFramework/RushSession'; @@ -84,7 +84,7 @@ export class CobuildConfiguration { this.cobuildContextId = EnvironmentConfiguration.cobuildContextId; this.cobuildFeatureEnabled = this.cobuildContextId ? cobuildJson.cobuildFeatureEnabled : false; - this.cobuildRunnerId = EnvironmentConfiguration.cobuildRunnerId || uuidv4(); + this.cobuildRunnerId = EnvironmentConfiguration.cobuildRunnerId || randomUUID(); this.cobuildLeafProjectLogOnlyAllowed = EnvironmentConfiguration.cobuildLeafProjectLogOnlyAllowed ?? false; this.cobuildWithoutCacheAllowed = From b14a819e4a2e0c7a0638054931b46c02b2f177ce Mon Sep 17 00:00:00 2001 From: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> Date: Wed, 17 Sep 2025 13:24:35 -0400 Subject: [PATCH 11/22] [lockfile-explorer] Rewrite lockfile parser to be more correct (#5363) * Add edge case for projects with duplicate names * Replace custom interfaces with official PNPM types; temporarily remove the V6 kludge * Add lockfilePath.ts utility that can completely eliminate `@lifaon/path` * Completed rewrite of 5.4 loader logic * Remove "@lifaon/path" dependency * Splite createLockfileEntry() into createProjectLockfileEntry() and createPackageLockfileEntry() * Upgrade to use @pnpm/lockfile.types@1002.0.1 * Implement support for lockfile version 6.0 * Hide the "." package from Rush workspaces, shuffling the jsonId's in all the snapshots * Fix an incorrect path * rush change * Improve formatting of 6.0 suffixes * Move entry to nonbrowser-approved-packages.json * Add a failing test case for "link:" in packages section * PR feedback * PR feedback: don't try to resolve "link:" under packages section * rush update --- .../packlets/lfx-shared/IJsonLfxWorkspace.ts | 31 +- apps/lockfile-explorer/.vscode/launch.json | 2 +- apps/lockfile-explorer/package.json | 6 +- .../cli/explorer/ExplorerCommandLineParser.ts | 9 +- .../src/cli/lint/actions/CheckAction.ts | 19 +- .../src/graph/lfxGraphLoader.ts | 578 +++++++++++------- .../src/graph/lockfilePath.ts | 122 ++++ .../lfxGraph-edge-cases-v5.4.test.ts.snap | 165 +++++ .../lfxGraph-edge-cases-v6.0.test.ts.snap | 165 +++++ ...fxGraph-website-sample-1-v5.4.test.ts.snap | 149 +++-- ...fxGraph-website-sample-1-v6.0.test.ts.snap | 192 +++--- .../fixtures/edge-cases/pnpm-lock-v5.4.yaml | 72 +++ .../fixtures/edge-cases/pnpm-lock-v6.0.yaml | 77 +++ .../fixtures/edge-cases/website-sample-1.md | 4 + .../src/graph/test/graphTestHelpers.ts | 4 +- .../test/lfxGraph-edge-cases-v5.4.test.ts | 23 + .../test/lfxGraph-edge-cases-v6.0.test.ts | 23 + .../lfxGraph-website-sample-1-v5.4.test.ts | 3 +- .../lfxGraph-website-sample-1-v6.0.test.ts | 3 +- .../src/graph/test/lockfile.test.ts | 4 +- .../src/graph/test/lockfilePath.test.ts | 57 ++ .../src/graph/test/serializeToJson.test.ts | 50 +- .../src/graph/test/testLockfile.ts | 12 +- apps/lockfile-explorer/src/utils/init.ts | 13 +- .../octogonz-lfx-fixes3_2025-09-16-11-01.json | 10 + .../rush/nonbrowser-approved-packages.json | 4 + .../subspaces/default/common-versions.json | 3 + .../config/subspaces/default/pnpm-lock.yaml | 50 +- .../config/subspaces/default/repo-state.json | 2 +- 29 files changed, 1344 insertions(+), 508 deletions(-) create mode 100644 apps/lockfile-explorer/src/graph/lockfilePath.ts create mode 100644 apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap create mode 100644 apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap create mode 100644 apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v5.4.yaml create mode 100644 apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v6.0.yaml create mode 100644 apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/website-sample-1.md create mode 100644 apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts create mode 100644 apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts create mode 100644 apps/lockfile-explorer/src/graph/test/lockfilePath.test.ts create mode 100644 common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes3_2025-09-16-11-01.json diff --git a/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts b/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts index 31c081d1c91..841f95e763d 100644 --- a/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts +++ b/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts @@ -3,7 +3,7 @@ export interface IJsonLfxWorkspaceRushConfig { /** - * The rushVersion from rush.json. + * The `rushVersion` field from rush.json. */ readonly rushVersion: string; @@ -16,19 +16,36 @@ export interface IJsonLfxWorkspaceRushConfig { export interface IJsonLfxWorkspace { /** - * Absolute path to the workspace folder that is opened by the app. - * Relative paths are generally relative to this path. + * Absolute path to the workspace folder that is opened by the app, normalized to use forward slashes + * without a trailing slash. + * + * @example `"C:/path/to/MyRepo"` */ - readonly workspaceRootFolder: string; + readonly workspaceRootFullPath: string; /** - * The path to the pnpm-lock.yaml file. + * The path to the "pnpm-lock.yaml" file, relative to `workspaceRootFullPath` + * and normalized to use forward slashes without a leading slash. + * + * @example `"common/temp/my-subspace/pnpm-lock.yaml"` + * @example `"pnpm-lock.yaml"` */ readonly pnpmLockfilePath: string; /** - * If this is a Rush workspace (versus a plain PNPM workspace), then - * this section will be defined. + * The path to the folder of "pnpm-lock.yaml" file, relative to `workspaceRootFullPath` + * and normalized to use forward slashes without a leading slash. + * + * If `pnpm-lack.yaml` is in the `workspaceRootFullPath` folder, then pnpmLockfileFolder + * is the empty string. + * + * @example `"common/temp/my-subspace"` + * @example `""` + */ + readonly pnpmLockfileFolder: string; + + /** + * This section will be defined only if this is a Rush workspace (versus a plain PNPM workspace). */ readonly rushConfig: IJsonLfxWorkspaceRushConfig | undefined; } diff --git a/apps/lockfile-explorer/.vscode/launch.json b/apps/lockfile-explorer/.vscode/launch.json index 9cc2fd9f58d..2ee133e0e98 100644 --- a/apps/lockfile-explorer/.vscode/launch.json +++ b/apps/lockfile-explorer/.vscode/launch.json @@ -20,7 +20,7 @@ "name": "Single Jest test", "program": "${workspaceFolder}/node_modules/@rushstack/heft/lib/start.js", "cwd": "${workspaceFolder}", - "args": ["--debug", "test", "--clean", "-u", "--test-path-pattern", "lfxGraphLoader60"], + "args": ["--debug", "test", "--clean", "-u", "--test-path-pattern", "lfxGraph-website-sample-1-v6.0.test"], "console": "integratedTerminal", "sourceMaps": true }, diff --git a/apps/lockfile-explorer/package.json b/apps/lockfile-explorer/package.json index 4e175cb7851..62e03b0be7f 100644 --- a/apps/lockfile-explorer/package.json +++ b/apps/lockfile-explorer/package.json @@ -39,7 +39,7 @@ "_phase:test": "heft run --only test -- --clean" }, "peerDependencies": { - "@types/express": "^4.17.21" + "@types/express": "^5.0.3" }, "peerDependenciesMeta": { "@types/express": { @@ -55,12 +55,12 @@ "@types/update-notifier": "~6.0.1", "eslint": "~9.25.1", "local-node-rig": "workspace:*", - "@pnpm/lockfile-types": "^5.1.5", + "@pnpm/lockfile.types": "1002.0.1", + "@pnpm/types": "1000.8.0", "@types/semver": "7.5.0" }, "dependencies": { "tslib": "~2.8.1", - "@lifaon/path": "~2.1.0", "@microsoft/rush-lib": "workspace:*", "@pnpm/dependency-path-lockfile-pre-v9": "npm:@pnpm/dependency-path@~2.1.2", "@rushstack/node-core-library": "workspace:*", diff --git a/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts b/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts index cc40653e354..cf0bd6e7839 100644 --- a/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts +++ b/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts @@ -15,7 +15,6 @@ import { CommandLineParser, type IRequiredCommandLineStringParameter } from '@rushstack/ts-command-line'; -import type { Lockfile } from '@pnpm/lockfile-types'; import { type LfxGraph, lfxGraphSerializer, @@ -152,13 +151,9 @@ export class ExplorerCommandLineParser extends CommandLineParser { app.get('/api/graph', async (req: express.Request, res: express.Response) => { const pnpmLockfileText: string = await FileSystem.readFileAsync(appState.pnpmLockfileLocation); - const lockfile: Lockfile = yaml.load(pnpmLockfileText) as Lockfile; + const lockfile: unknown = yaml.load(pnpmLockfileText) as unknown; - const graph: LfxGraph = lfxGraphLoader.generateLockfileGraph( - appState.lfxWorkspace, - lockfile as lfxGraphLoader.ILockfilePackageType, - appState.lfxWorkspace.rushConfig?.subspaceName ?? '' - ); + const graph: LfxGraph = lfxGraphLoader.generateLockfileGraph(lockfile, appState.lfxWorkspace); const jsonGraph: IJsonLfxGraph = lfxGraphSerializer.serializeToJson(graph); res.type('application/json').send(jsonGraph); diff --git a/apps/lockfile-explorer/src/cli/lint/actions/CheckAction.ts b/apps/lockfile-explorer/src/cli/lint/actions/CheckAction.ts index 389bb27001b..12390a1b274 100644 --- a/apps/lockfile-explorer/src/cli/lint/actions/CheckAction.ts +++ b/apps/lockfile-explorer/src/cli/lint/actions/CheckAction.ts @@ -7,6 +7,8 @@ import { RushConfiguration, type RushConfigurationProject, type Subspace } from import path from 'path'; import yaml from 'js-yaml'; import semver from 'semver'; +import type * as lockfileTypes from '@pnpm/lockfile.types'; +import type * as pnpmTypes from '@pnpm/types'; import { AlreadyReportedError, Async, FileSystem, JsonFile, JsonSchema } from '@rushstack/node-core-library'; import lockfileLintSchema from '../../../schemas/lockfile-lint.schema.json'; @@ -17,7 +19,6 @@ import { parseDependencyPath, splicePackageWithVersion } from '../../../utils/shrinkwrap'; -import type { Lockfile, LockfileV6 } from '@pnpm/lockfile-types'; export interface ILintRule { rule: 'restrict-versions'; @@ -40,7 +41,7 @@ export class CheckAction extends CommandLineAction { private _rushConfiguration!: RushConfiguration; private _checkedProjects: Set; - private _docMap: Map; + private _docMap: Map; public constructor(parser: LintCommandLineParser) { super({ @@ -59,8 +60,8 @@ export class CheckAction extends CommandLineAction { private async _checkVersionCompatibilityAsync( shrinkwrapFileMajorVersion: number, - packages: Lockfile['packages'], - dependencyPath: string, + packages: lockfileTypes.PackageSnapshots | undefined, + dependencyPath: pnpmTypes.DepPath, requiredVersions: Record, checkedDependencyPaths: Set ): Promise { @@ -84,7 +85,7 @@ export class CheckAction extends CommandLineAction { shrinkwrapFileMajorVersion, dependencyPackageName, dependencyPackageVersion - ), + ) as pnpmTypes.DepPath, requiredVersions, checkedDependencyPaths ); @@ -103,12 +104,12 @@ export class CheckAction extends CommandLineAction { const projectFolder: string = project.projectFolder; const subspace: Subspace = project.subspace; const shrinkwrapFilename: string = subspace.getCommittedShrinkwrapFilePath(); - let doc: Lockfile | LockfileV6; + let doc: lockfileTypes.LockfileObject; if (this._docMap.has(shrinkwrapFilename)) { doc = this._docMap.get(shrinkwrapFilename)!; } else { const pnpmLockfileText: string = await FileSystem.readFileAsync(shrinkwrapFilename); - doc = yaml.load(pnpmLockfileText) as Lockfile | LockfileV6; + doc = yaml.load(pnpmLockfileText) as lockfileTypes.LockfileObject; this._docMap.set(shrinkwrapFilename, doc); } const { importers, lockfileVersion, packages } = doc; @@ -120,7 +121,7 @@ export class CheckAction extends CommandLineAction { if (path.resolve(projectFolder, relativePath) === projectFolder) { const dependenciesEntries: [string, unknown][] = Object.entries(dependencies ?? {}); for (const [dependencyName, dependencyValue] of dependenciesEntries) { - const fullDependencyPath: string = splicePackageWithVersion( + const fullDependencyPath: pnpmTypes.DepPath = splicePackageWithVersion( shrinkwrapFileMajorVersion, dependencyName, typeof dependencyValue === 'string' @@ -131,7 +132,7 @@ export class CheckAction extends CommandLineAction { specifier: string; } ).version - ); + ) as pnpmTypes.DepPath; if (fullDependencyPath.includes('link:')) { const dependencyProject: RushConfigurationProject | undefined = this._rushConfiguration.getProjectByName(dependencyName); diff --git a/apps/lockfile-explorer/src/graph/lfxGraphLoader.ts b/apps/lockfile-explorer/src/graph/lfxGraphLoader.ts index 64ed34f997a..ba421861c21 100644 --- a/apps/lockfile-explorer/src/graph/lfxGraphLoader.ts +++ b/apps/lockfile-explorer/src/graph/lfxGraphLoader.ts @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { Path } from '@lifaon/path'; +import type * as lockfileTypes from '@pnpm/lockfile.types'; +import type * as pnpmTypes from '@pnpm/types'; +import { Text } from '@rushstack/node-core-library'; import { type ILfxGraphDependencyOptions, @@ -13,77 +15,29 @@ import { LfxGraphDependency, type IJsonLfxWorkspace } from '../../build/lfx-shared'; +import * as lockfilePath from './lockfilePath'; + +type PnpmLockfileVersion = 54 | 60 | 90; +type PeerDependenciesMeta = lockfileTypes.LockfilePackageInfo['peerDependenciesMeta']; + +function createPackageLockfileDependency(options: { + name: string; + version: string; + kind: LfxDependencyKind; + containingEntry: LfxGraphEntry; + peerDependenciesMeta?: PeerDependenciesMeta; + pnpmLockfileVersion: PnpmLockfileVersion; + workspace: IJsonLfxWorkspace; +}): LfxGraphDependency { + const { + name, + version, + kind: dependencyType, + containingEntry, + peerDependenciesMeta, + pnpmLockfileVersion + } = options; -import { convertLockfileV6DepPathToV5DepPath } from '../utils/shrinkwrap'; - -enum PnpmLockfileVersion { - V6, - V5 -} - -export interface ILockfileImporterV6 { - dependencies?: { - [key: string]: { - specifier: string; - version: string; - }; - }; - devDependencies?: { - [key: string]: { - specifier: string; - version: string; - }; - }; -} -export interface ILockfileImporterV5 { - specifiers?: Record; - dependencies?: Record; - devDependencies?: Record; -} -export interface ILockfilePackageType { - lockfileVersion: number | string; - importers?: { - [key: string]: ILockfileImporterV5 | ILockfileImporterV6; - }; - packages?: { - [key: string]: { - resolution: { - integrity: string; - }; - dependencies?: Record; - peerDependencies?: Record; - dev: boolean; - }; - }; -} - -export interface ILockfileNode { - dependencies?: { - [key: string]: string; - }; - devDependencies?: { - [key: string]: string; - }; - peerDependencies?: { - [key: string]: string; - }; - peerDependenciesMeta?: { - [key: string]: { - optional: boolean; - }; - }; - transitivePeerDependencies?: string[]; -} - -const packageEntryIdRegex: RegExp = new RegExp('/(.*)/([^/]+)$'); - -function createLockfileDependency( - name: string, - version: string, - dependencyType: LfxDependencyKind, - containingEntry: LfxGraphEntry, - node?: ILockfileNode -): LfxGraphDependency { const result: ILfxGraphDependencyOptions = { name, version, @@ -95,58 +49,91 @@ function createLockfileDependency( if (version.startsWith('link:')) { const relativePath: string = version.substring('link:'.length); - const rootRelativePath: Path | null = new Path('.').relative( - new Path(containingEntry.packageJsonFolderPath).concat(relativePath) - ); - if (!rootRelativePath) { - console.error('No root relative path for dependency!', name); - return new LfxGraphDependency(result); + + if (containingEntry.kind === LfxGraphEntryKind.Project) { + // TODO: Here we assume it's a "workspace:" link and try to resolve it to another workspace project, + // but it could also be a link to an arbitrary folder (in which case this entryId will fail to resolve). + // In the future, we should distinguish these cases. + const selfRelativePath: string = lockfilePath.getAbsolute( + containingEntry.packageJsonFolderPath, + relativePath + ); + result.entryId = 'project:' + selfRelativePath.toString(); + } else { + // This could be a link to anywhere on the local computer, so we don't expect it to have a lockfile entry + result.entryId = ''; } - result.entryId = 'project:' + rootRelativePath.toString(); } else if (result.version.startsWith('/')) { result.entryId = version; } else if (result.dependencyType === LfxDependencyKind.Peer) { - if (node?.peerDependencies) { - result.peerDependencyMeta = { - name: result.name, - version: node.peerDependencies[result.name], - optional: - node.peerDependenciesMeta && node.peerDependenciesMeta[result.name] - ? node.peerDependenciesMeta[result.name].optional - : false - }; - result.entryId = 'Peer: ' + result.name; - } else { - console.error('Peer dependencies info missing!', node); - } + result.peerDependencyMeta = { + name: result.name, + version: version, + optional: peerDependenciesMeta?.[result.name] ? peerDependenciesMeta[result.name].optional : false + }; + result.entryId = 'Peer: ' + result.name; } else { - result.entryId = '/' + result.name + '/' + result.version; + // Version 5.4: /@rushstack/m/1.0.0: + // Version 6.0: /@rushstack/m@1.0.0: + // + // Version 5.4: /@rushstack/j/1.0.0_@rushstack+n@2.0.0 + // Version 6.0: /@rushstack/j@1.0.0(@rushstack/n@2.0.0) + const versionDelimiter: string = pnpmLockfileVersion === 54 ? '/' : '@'; + result.entryId = '/' + result.name + versionDelimiter + result.version; } return new LfxGraphDependency(result); } -// node is the yaml entry that we are trying to parse -function parseDependencies( +// v5.4 used this to parse projects ("importers") also +function parsePackageDependencies( dependencies: LfxGraphDependency[], lockfileEntry: LfxGraphEntry, - node: ILockfileNode + either: lockfileTypes.ProjectSnapshot | lockfileTypes.PackageSnapshot, + pnpmLockfileVersion: PnpmLockfileVersion, + workspace: IJsonLfxWorkspace ): void { + const node: Partial = + either as unknown as Partial; if (node.dependencies) { - for (const [pkgName, pkgVersion] of Object.entries(node.dependencies)) { + for (const [packageName, version] of Object.entries(node.dependencies)) { dependencies.push( - createLockfileDependency(pkgName, pkgVersion, LfxDependencyKind.Regular, lockfileEntry) + createPackageLockfileDependency({ + kind: LfxDependencyKind.Regular, + name: packageName, + version: version, + containingEntry: lockfileEntry, + pnpmLockfileVersion, + workspace + }) ); } } if (node.devDependencies) { - for (const [pkgName, pkgVersion] of Object.entries(node.devDependencies)) { - dependencies.push(createLockfileDependency(pkgName, pkgVersion, LfxDependencyKind.Dev, lockfileEntry)); + for (const [packageName, version] of Object.entries(node.devDependencies)) { + dependencies.push( + createPackageLockfileDependency({ + kind: LfxDependencyKind.Dev, + name: packageName, + version: version, + containingEntry: lockfileEntry, + pnpmLockfileVersion, + workspace + }) + ); } } if (node.peerDependencies) { - for (const [pkgName, pkgVersion] of Object.entries(node.peerDependencies)) { + for (const [packageName, version] of Object.entries(node.peerDependencies)) { dependencies.push( - createLockfileDependency(pkgName, pkgVersion, LfxDependencyKind.Peer, lockfileEntry, node) + createPackageLockfileDependency({ + kind: LfxDependencyKind.Peer, + name: packageName, + version: version, + containingEntry: lockfileEntry, + peerDependenciesMeta: node.peerDependenciesMeta, + pnpmLockfileVersion, + workspace + }) ); } } @@ -157,17 +144,53 @@ function parseDependencies( } } -function createLockfileEntry(options: { +function parseProjectDependencies60( + dependencies: LfxGraphDependency[], + lockfileEntry: LfxGraphEntry, + snapshot: lockfileTypes.LockfileFileProjectSnapshot, + pnpmLockfileVersion: PnpmLockfileVersion, + workspace: IJsonLfxWorkspace +): void { + if (snapshot.dependencies) { + for (const [packageName, specifierAndResolution] of Object.entries(snapshot.dependencies)) { + dependencies.push( + createPackageLockfileDependency({ + kind: LfxDependencyKind.Regular, + name: packageName, + version: specifierAndResolution.version, + containingEntry: lockfileEntry, + pnpmLockfileVersion, + workspace + }) + ); + } + } + if (snapshot.devDependencies) { + for (const [packageName, specifierAndResolution] of Object.entries(snapshot.devDependencies)) { + dependencies.push( + createPackageLockfileDependency({ + kind: LfxDependencyKind.Dev, + name: packageName, + version: specifierAndResolution.version, + containingEntry: lockfileEntry, + pnpmLockfileVersion, + workspace + }) + ); + } + } +} + +function createProjectLockfileEntry(options: { rawEntryId: string; - kind: LfxGraphEntryKind; - rawYamlData: ILockfileNode; duplicates?: Set; - subspaceName?: string; + workspace: IJsonLfxWorkspace; + pnpmLockfileVersion: PnpmLockfileVersion; }): LfxGraphEntry { - const { rawEntryId, kind, rawYamlData, duplicates, subspaceName } = options; + const { rawEntryId, duplicates, workspace } = options; const result: ILfxGraphEntryOptions = { - kind, + kind: LfxGraphEntryKind.Project, entryId: '', rawEntryId: '', packageJsonFolderPath: '', @@ -179,111 +202,163 @@ function createLockfileEntry(options: { result.rawEntryId = rawEntryId; - if (rawEntryId === '.') { - // Project Root - return new LfxGraphEntry(result); - } + // Example: pnpmLockfilePath = 'common/temp/my-subspace/pnpm-lock.yaml' + // Example: pnpmLockfileFolder = 'common/temp/my-subspace' + const pnpmLockfileFolder: string = workspace.pnpmLockfileFolder; - if (kind === LfxGraphEntryKind.Project) { - const rootPackageJsonFolderPath: '' | Path = - new Path(`common/temp/${subspaceName}/package.json`).dirname() || ''; - const packageJsonFolderPath: Path | null = new Path('.').relative( - new Path(rootPackageJsonFolderPath).concat(rawEntryId) - ); - const packageName: string | null = new Path(rawEntryId).basename(); - - if (!packageJsonFolderPath || !packageName) { - console.error('Could not construct path for entry: ', rawEntryId); - return new LfxGraphEntry(result); - } + // Example: rawEntryId = '../../../projects/a' + // Example: packageJsonFolderPath = 'projects/a' + result.packageJsonFolderPath = lockfilePath.getAbsolute(pnpmLockfileFolder, rawEntryId); + result.entryId = 'project:' + result.packageJsonFolderPath; - result.packageJsonFolderPath = packageJsonFolderPath.toString(); - result.entryId = 'project:' + result.packageJsonFolderPath; - result.entryPackageName = packageName.toString(); - if (duplicates?.has(result.entryPackageName)) { - const fullPath: string = new Path(rawEntryId).makeAbsolute('/').toString().substring(1); - result.displayText = `Project: ${result.entryPackageName} (${fullPath})`; - result.entryPackageName = `${result.entryPackageName} (${fullPath})`; - } else { - result.displayText = 'Project: ' + result.entryPackageName; - } + const projectFolderName: string = lockfilePath.getBaseNameOf(rawEntryId); + + if (!duplicates?.has(projectFolderName)) { + // TODO: The actual package.json name might not match its directory name, + // but we have to load package.json to determine it. + result.entryPackageName = projectFolderName; } else { - result.displayText = rawEntryId; + result.entryPackageName = `${projectFolderName} (${result.packageJsonFolderPath})`; + } + result.displayText = `Project: ${result.entryPackageName}`; + + const lockfileEntry: LfxGraphEntry = new LfxGraphEntry(result); + return lockfileEntry; +} - const match: RegExpExecArray | null = packageEntryIdRegex.exec(rawEntryId); +function createPackageLockfileEntry(options: { + rawEntryId: string; + rawYamlData: lockfileTypes.PackageSnapshot; + workspace: IJsonLfxWorkspace; + pnpmLockfileVersion: PnpmLockfileVersion; +}): LfxGraphEntry { + const { rawEntryId, rawYamlData, pnpmLockfileVersion, workspace } = options; - if (match) { - const [, packageName, versionPart] = match; - result.entryPackageName = packageName; + const result: ILfxGraphEntryOptions = { + kind: LfxGraphEntryKind.Package, + entryId: '', + rawEntryId: '', + packageJsonFolderPath: '', + entryPackageName: '', + displayText: '', + entryPackageVersion: '', + entrySuffix: '' + }; - const underscoreIndex: number = versionPart.indexOf('_'); - if (underscoreIndex >= 0) { - const version: string = versionPart.substring(0, underscoreIndex); - const suffix: string = versionPart.substring(underscoreIndex + 1); + result.rawEntryId = rawEntryId; - result.entryPackageVersion = version; - result.entrySuffix = suffix; + // Example: pnpmLockfilePath = 'common/temp/my-subspace/pnpm-lock.yaml' + // Example: pnpmLockfileFolder = 'common/temp/my-subspace' + const pnpmLockfileFolder: string = workspace.pnpmLockfileFolder; - // /@rushstack/eslint-config/3.0.1_eslint@8.21.0+typescript@4.7.4 - // --> @rushstack/eslint-config 3.0.1 (eslint@8.21.0+typescript@4.7.4) - result.displayText = packageName + ' ' + version + ' (' + suffix + ')'; - } else { - result.entryPackageVersion = versionPart; + result.displayText = rawEntryId; - // /@rushstack/eslint-config/3.0.1 - // --> @rushstack/eslint-config 3.0.1 - result.displayText = packageName + ' ' + versionPart; - } + if (!rawEntryId.startsWith('/')) { + throw new Error('Expecting leading "/" in path: ' + JSON.stringify(rawEntryId)); + } + + let dotPnpmSubfolder: string; + + if (pnpmLockfileVersion === 54) { + const lastSlashIndex: number = rawEntryId.lastIndexOf('/'); + if (lastSlashIndex < 0) { + throw new Error('Expecting "/" in path: ' + JSON.stringify(rawEntryId)); + } + const packageName: string = rawEntryId.substring(1, lastSlashIndex); + result.entryPackageName = packageName; + + // /@rushstack/eslint-config/3.0.1_eslint@8.21.0+typescript@4.7.4 + // --> @rushstack/eslint-config 3.0.1 (eslint@8.21.0+typescript@4.7.4) + const underscoreIndex: number = rawEntryId.indexOf('_', lastSlashIndex); + if (underscoreIndex > 0) { + const version: string = rawEntryId.substring(lastSlashIndex + 1, underscoreIndex); + const suffix: string = rawEntryId.substring(underscoreIndex + 1); + result.displayText = packageName + ' ' + version + ' (' + suffix + ')'; + result.entryPackageVersion = version; + result.entrySuffix = suffix; + } else { + // /@rushstack/eslint-config/3.0.1 + // --> @rushstack/eslint-config 3.0.1 + const version: string = rawEntryId.substring(lastSlashIndex + 1); + result.displayText = packageName + ' ' + version; + result.entryPackageVersion = version; } - // Example: - // common/temp/default/node_modules/.pnpm - // /@babel+register@7.17.7_@babel+core@7.17.12 - // /node_modules/@babel/register - result.packageJsonFolderPath = - `common/temp/${subspaceName}/node_modules/.pnpm/` + + // Example: @babel+register@7.17.7_@babel+core@7.17.12 + dotPnpmSubfolder = result.entryPackageName.replace('/', '+') + '@' + result.entryPackageVersion + - (result.entrySuffix ? `_${result.entrySuffix}` : '') + - '/node_modules/' + - result.entryPackageName; - } + (result.entrySuffix ? `_${result.entrySuffix}` : ''); + } else { + // Example inputs: + // /@rushstack/eslint-config@3.0.1 + // /@rushstack/l@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + let versionAtSignIndex: number; + if (rawEntryId.startsWith('/@')) { + versionAtSignIndex = rawEntryId.indexOf('@', 2); + } else { + versionAtSignIndex = rawEntryId.indexOf('@', 1); + } + const packageName: string = rawEntryId.substring(1, versionAtSignIndex); + result.entryPackageName = packageName; - const lockfileEntry: LfxGraphEntry = new LfxGraphEntry(result); - parseDependencies(lockfileEntry.dependencies, lockfileEntry, rawYamlData); - return lockfileEntry; -} + const leftParenIndex: number = rawEntryId.indexOf('(', versionAtSignIndex); + if (leftParenIndex < 0) { + const version: string = rawEntryId.substring(versionAtSignIndex + 1); + result.entryPackageVersion = version; -/** - * Transform any newer lockfile formats to the following format: - * [packageName]: - * specifier: ... - * version: ... - */ -function getImporterValue( - importerValue: ILockfileImporterV5 | ILockfileImporterV6, - pnpmLockfileVersion: PnpmLockfileVersion -): ILockfileImporterV5 { - if (pnpmLockfileVersion === PnpmLockfileVersion.V6) { - const v6ImporterValue: ILockfileImporterV6 = importerValue as ILockfileImporterV6; - const v5ImporterValue: ILockfileImporterV5 = { - specifiers: {}, - dependencies: {}, - devDependencies: {} - }; - for (const [depName, depDetails] of Object.entries(v6ImporterValue.dependencies ?? {})) { - v5ImporterValue.specifiers![depName] = depDetails.specifier; - v5ImporterValue.dependencies![depName] = depDetails.version; - } - for (const [depName, depDetails] of Object.entries(v6ImporterValue.devDependencies ?? {})) { - v5ImporterValue.specifiers![depName] = depDetails.specifier; - v5ImporterValue.devDependencies![depName] = depDetails.version; + // /@rushstack/eslint-config@3.0.1 + // --> @rushstack/eslint-config 3.0.1 + result.displayText = packageName + ' ' + version; + } else { + const version: string = rawEntryId.substring(versionAtSignIndex + 1, leftParenIndex); + result.entryPackageVersion = version; + + // "(@rushstack/m@1.0.0)(@rushstack/n@2.0.0)" + let suffix: string = rawEntryId.substring(leftParenIndex); + + // Rewrite to: + // "@rushstack/m@1.0.0; @rushstack/n@2.0.0" + suffix = Text.replaceAll(suffix, ')(', '; '); + suffix = Text.replaceAll(suffix, '(', ''); + suffix = Text.replaceAll(suffix, ')', ''); + result.entrySuffix = suffix; + + // /@rushstack/l@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + // --> @rushstack/l 1.0.0 [@rushstack/m@1.0.0; @rushstack/n@2.0.0] + result.displayText = packageName + ' ' + version + ' [' + suffix + ']'; } - return v5ImporterValue; - } else { - return importerValue as ILockfileImporterV5; + + // Example: /@rushstack/l@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + // --> @rushstack+l@1.0.0_@rushstack+m@1.0.0_@rushstack+n@2.0.0 + + // @rushstack/l 1.0.0 (@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + dotPnpmSubfolder = rawEntryId.substring(1); + dotPnpmSubfolder = Text.replaceAll(dotPnpmSubfolder, '/', '+'); + dotPnpmSubfolder = Text.replaceAll(dotPnpmSubfolder, ')(', '_'); + dotPnpmSubfolder = Text.replaceAll(dotPnpmSubfolder, '(', '_'); + dotPnpmSubfolder = Text.replaceAll(dotPnpmSubfolder, ')', ''); } + + // Example: + // common/temp/default/node_modules/.pnpm + // /@babel+register@7.17.7_@babel+core@7.17.12 + // /node_modules/@babel/register + result.packageJsonFolderPath = lockfilePath.join( + pnpmLockfileFolder, + `node_modules/.pnpm/` + dotPnpmSubfolder + '/node_modules/' + result.entryPackageName + ); + + const lockfileEntry: LfxGraphEntry = new LfxGraphEntry(result); + parsePackageDependencies( + lockfileEntry.dependencies, + lockfileEntry, + rawYamlData, + pnpmLockfileVersion, + workspace + ); + return lockfileEntry; } /** @@ -292,77 +367,110 @@ function getImporterValue( * * @returns A list of all the LockfileEntries in the lockfile. */ -export function generateLockfileGraph( - workspace: IJsonLfxWorkspace, - lockfile: ILockfilePackageType, - subspaceName?: string -): LfxGraph { - let pnpmLockfileVersion: PnpmLockfileVersion = PnpmLockfileVersion.V5; - if (parseInt(lockfile.lockfileVersion.toString(), 10) === 6) { - pnpmLockfileVersion = PnpmLockfileVersion.V6; - } - - if (lockfile.packages && pnpmLockfileVersion === PnpmLockfileVersion.V6) { - const updatedPackages: ILockfilePackageType['packages'] = {}; - for (const [dependencyPath, dependency] of Object.entries(lockfile.packages)) { - updatedPackages[convertLockfileV6DepPathToV5DepPath(dependencyPath)] = dependency; - } - lockfile.packages = updatedPackages; +export function generateLockfileGraph(lockfileJson: unknown, workspace: IJsonLfxWorkspace): LfxGraph { + const lockfile: lockfileTypes.LockfileObject | lockfileTypes.LockfileFile = lockfileJson as + | lockfileTypes.LockfileObject + | lockfileTypes.LockfileFile; + + let pnpmLockfileVersion: PnpmLockfileVersion; + switch (lockfile.lockfileVersion.toString()) { + case '5.4': + pnpmLockfileVersion = 54; + break; + case '6': + case '6.0': + pnpmLockfileVersion = 60; + break; + //case '9': + //case '9.0': + // pnpmLockfileVersion = 90; + // break; + default: + throw new Error('Unsupported PNPM lockfile version ' + JSON.stringify(lockfile.lockfileVersion)); } const lfxGraph: LfxGraph = new LfxGraph(workspace); const allEntries: LfxGraphEntry[] = lfxGraph.entries; - const allEntriesById: { [key: string]: LfxGraphEntry } = {}; + const allEntriesById: Map = new Map(); const allImporters: LfxGraphEntry[] = []; + + // "Importers" are the local workspace projects if (lockfile.importers) { - // Find duplicate importer names + // Normally the UX shows the concise project folder name. However in the case of duplicates + // (where two projects use the same folder name), then we will need to disambiguate. const baseNames: Set = new Set(); const duplicates: Set = new Set(); for (const importerKey of Object.keys(lockfile.importers)) { - const baseName: string | null = new Path(importerKey).basename(); - if (baseName) { - if (baseNames.has(baseName)) { - duplicates.add(baseName); - } - baseNames.add(baseName); + const baseName: string = lockfilePath.getBaseNameOf(importerKey); + if (baseNames.has(baseName)) { + duplicates.add(baseName); } + baseNames.add(baseName); } - for (const [importerKey, importerValue] of Object.entries(lockfile.importers)) { - // console.log('normalized importer key: ', new Path(importerKey).makeAbsolute('/').toString()); + const isRushWorkspace: boolean = workspace.rushConfig !== undefined; - // const normalizedPath = new Path(importerKey).makeAbsolute('/').toString(); - const importer: LfxGraphEntry = createLockfileEntry({ - // entryId: normalizedPath, + for (const importerKey of Object.keys(lockfile.importers)) { + if (isRushWorkspace && importerKey === '.') { + // Discard the synthetic package.json file created by Rush under common/temp + continue; + } + + const importer: LfxGraphEntry = createProjectLockfileEntry({ rawEntryId: importerKey, - kind: LfxGraphEntryKind.Project, - rawYamlData: getImporterValue(importerValue, pnpmLockfileVersion), duplicates, - subspaceName + workspace, + pnpmLockfileVersion }); + + if (pnpmLockfileVersion === 54) { + const lockfile54: lockfileTypes.LockfileObject = lockfileJson as lockfileTypes.LockfileObject; + const importerValue: lockfileTypes.ProjectSnapshot = + lockfile54.importers[importerKey as pnpmTypes.ProjectId]; + parsePackageDependencies( + importer.dependencies, + importer, + importerValue, + pnpmLockfileVersion, + workspace + ); + } else { + const lockfile60: lockfileTypes.LockfileFile = lockfileJson as lockfileTypes.LockfileFile; + if (lockfile60.importers) { + const importerValue: lockfileTypes.LockfileFileProjectSnapshot = + lockfile60.importers[importerKey as pnpmTypes.ProjectId]; + parseProjectDependencies60( + importer.dependencies, + importer, + importerValue, + pnpmLockfileVersion, + workspace + ); + } + } + allImporters.push(importer); allEntries.push(importer); - allEntriesById[importer.entryId] = importer; + allEntriesById.set(importer.entryId, importer); } } const allPackages: LfxGraphEntry[] = []; if (lockfile.packages) { - for (const [dependencyKey, dependencyValue] of Object.entries(lockfile.packages)) { + for (const [dependencyKey, dependencyValue] of Object.entries(lockfile.packages ?? {})) { // const normalizedPath = new Path(dependencyKey).makeAbsolute('/').toString(); - const currEntry: LfxGraphEntry = createLockfileEntry({ - // entryId: normalizedPath, + const currEntry: LfxGraphEntry = createPackageLockfileEntry({ rawEntryId: dependencyKey, - kind: LfxGraphEntryKind.Package, - rawYamlData: dependencyValue, - subspaceName + rawYamlData: dependencyValue as lockfileTypes.PackageSnapshot, + workspace, + pnpmLockfileVersion }); allPackages.push(currEntry); allEntries.push(currEntry); - allEntriesById[dependencyKey] = currEntry; + allEntriesById.set(dependencyKey, currEntry); } } @@ -374,7 +482,7 @@ export function generateLockfileGraph( continue; } - const matchedEntry: LfxGraphEntry = allEntriesById[dependency.entryId]; + const matchedEntry: LfxGraphEntry | undefined = allEntriesById.get(dependency.entryId); if (matchedEntry) { // Create a two-way link between the dependency and the entry dependency.resolvedEntry = matchedEntry; diff --git a/apps/lockfile-explorer/src/graph/lockfilePath.ts b/apps/lockfile-explorer/src/graph/lockfilePath.ts new file mode 100644 index 00000000000..1a03e4c58f3 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/lockfilePath.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * For example, retrieves `d` from `/a/b/c/d`. + */ +export function getBaseNameOf(importerPath: string): string { + if (importerPath.length === 0) { + return ''; + } + + const index: number = importerPath.lastIndexOf('/'); + if (index === importerPath.length - 1) { + throw new Error('Error: Path has a trailing slash'); + } + if (index >= 0) { + return importerPath.substring(index + 1); + } + return importerPath; +} + +/** + * For example, retrieves `/a/b/c` from `/a/b/c/d`. + */ +export function getParentOf(importerPath: string): string { + if (importerPath === '' || importerPath === '.' || importerPath === '/') { + throw new Error('Error: Path has no parent'); + } + + const index: number = importerPath.lastIndexOf('/'); + if (index === importerPath.length - 1) { + throw new Error('Error: Path has a trailing slash'); + } + if (index === 0) { + return '/'; + } + if (index < 0) { + return '.'; + } + return importerPath.substring(0, index); +} + +/** + * Cheaply resolves a relative path against a base path, assuming the paths are delimited by `/`, + * and assuming the basePath is already in normal form. An error occurs if the relative path + * goes above the root folder. + * + * @example + * ```ts + * getAbsolutePath(`a/b/c`, `d/e`) === `a/b/c/d/e` + * getAbsolutePath(`/a/b/c`, `d/e`) === `/a/b/c/d/e` + * getAbsolutePath(`/a/b/c`, `/d/e`) === `/d/e` + * getAbsolutePath(`a/b/c`, `../../f`) === `a/f` + * getAbsolutePath(`a/b/c`, `.././/f`) === `a/b/f` + * getAbsolutePath(`a/b/c`, `../../..`) === `.` + * getAbsolutePath(`C:/a/b`, `../d`) === `C:/a/d` + * getAbsolutePath(`a/b/c`, `../../../..`) === ERROR + * + * // Degenerate cases: + * getAbsolutePath(`a/b/c/`, `d/`) === `a/b/c/d` // trailing slashes are discarded + * getAbsolutePath(`./../c`, `d`) === `./../c/d` // basePath assumed to be normal form + * getAbsolutePath(`C:\\`, `\\a`) === `C:\\/\\a` // backslashes not supported + * ``` + */ +export function getAbsolute(basePath: string, relativePath: string): string { + let leadingSlash: boolean; + let stack: string[]; + + // Discard intermediary slashes + const relativeParts: string[] = relativePath.split('/').filter((part: string) => part.length > 0); + if (relativePath.startsWith('/')) { + stack = []; + leadingSlash = true; + } else { + // Discard intermediary slashes + stack = basePath.split('/').filter((part: string) => part.length > 0); + leadingSlash = basePath.startsWith('/'); + } + + for (const part of relativeParts) { + if (part === '.') { + // current directory, do nothing + continue; + } else if (part === '..') { + if (stack.length === 0) { + throw new Error('getAbsolutePath(): relativePath goes above the root folder'); + } + stack.pop(); + } else { + stack.push(part); + } + } + if (leadingSlash) { + return '/' + stack.join('/'); + } else { + return stack.length === 0 ? '.' : stack.join('/'); + } +} + +/** + * Returns the two parts joined by exactly one `/`, assuming the parts are already + * in normalized form. The `/` is not added if either part is an empty string. + */ +export function join(leftPart: string, rightPart: string): string { + if (leftPart.length === 0) { + return rightPart; + } + if (rightPart.length === 0) { + return leftPart; + } + + const leftEndsWithSlash: boolean = leftPart[leftPart.length - 1] === '/'; + const rightStartsWithSlash: boolean = rightPart[0] === '/'; + + if (leftEndsWithSlash && rightStartsWithSlash) { + return leftPart + rightPart.substring(1); + } + if (leftEndsWithSlash || rightStartsWithSlash) { + return leftPart + rightPart; + } + return leftPart + '/' + rightPart; +} diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap new file mode 100644 index 00000000000..382a4991800 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lfxGraph-edge-cases-v5.4 loads a workspace 1`] = ` +"entries: + - dependencies: + - dependencyType: regular + entryId: /color/5.0.2 + name: color + peerDependencyMeta: {} + resolvedEntryJsonId: 6 + version: 5.0.2 + displayText: 'Project: duplicate (duplicate-1/duplicate)' + entryId: project:duplicate-1/duplicate + entryPackageName: duplicate (duplicate-1/duplicate) + entryPackageVersion: '' + entrySuffix: '' + jsonId: 0 + kind: 1 + packageJsonFolderPath: duplicate-1/duplicate + rawEntryId: duplicate-1/duplicate + referrerJsonIds: [] + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-string/2.1.2 + name: color-string + peerDependencyMeta: {} + resolvedEntryJsonId: 5 + version: 2.1.2 + displayText: 'Project: duplicate (duplicate-2/duplicate)' + entryId: project:duplicate-2/duplicate + entryPackageName: duplicate (duplicate-2/duplicate) + entryPackageVersion: '' + entrySuffix: '' + jsonId: 1 + kind: 1 + packageJsonFolderPath: duplicate-2/duplicate + rawEntryId: duplicate-2/duplicate + referrerJsonIds: [] + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /has-symbols/1.0.2 + name: has-symbols + peerDependencyMeta: {} + resolvedEntryJsonId: 7 + version: 1.0.2 + - dependencyType: regular + entryId: project:link-specifier/target-folder + name: target-folder + peerDependencyMeta: {} + version: link:../target-folder + displayText: 'Project: linker' + entryId: project:link-specifier/linker + entryPackageName: linker + entryPackageVersion: '' + entrySuffix: '' + jsonId: 2 + kind: 1 + packageJsonFolderPath: link-specifier/linker + rawEntryId: link-specifier/linker + referrerJsonIds: [] + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-name/2.0.2 + name: color-name + peerDependencyMeta: {} + resolvedEntryJsonId: 4 + version: 2.0.2 + displayText: color-convert 3.1.2 + entryId: '' + entryPackageName: color-convert + entryPackageVersion: 3.1.2 + entrySuffix: '' + jsonId: 3 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color-convert@3.1.2/node_modules/color-convert + rawEntryId: /color-convert/3.1.2 + referrerJsonIds: + - 6 + transitivePeerDependencies: [] + - dependencies: [] + displayText: color-name 2.0.2 + entryId: '' + entryPackageName: color-name + entryPackageVersion: 2.0.2 + entrySuffix: '' + jsonId: 4 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color-name@2.0.2/node_modules/color-name + rawEntryId: /color-name/2.0.2 + referrerJsonIds: + - 3 + - 5 + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-name/2.0.2 + name: color-name + peerDependencyMeta: {} + resolvedEntryJsonId: 4 + version: 2.0.2 + displayText: color-string 2.1.2 + entryId: '' + entryPackageName: color-string + entryPackageVersion: 2.1.2 + entrySuffix: '' + jsonId: 5 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color-string@2.1.2/node_modules/color-string + rawEntryId: /color-string/2.1.2 + referrerJsonIds: + - 1 + - 6 + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-convert/3.1.2 + name: color-convert + peerDependencyMeta: {} + resolvedEntryJsonId: 3 + version: 3.1.2 + - dependencyType: regular + entryId: /color-string/2.1.2 + name: color-string + peerDependencyMeta: {} + resolvedEntryJsonId: 5 + version: 2.1.2 + displayText: color 5.0.2 + entryId: '' + entryPackageName: color + entryPackageVersion: 5.0.2 + entrySuffix: '' + jsonId: 6 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color@5.0.2/node_modules/color + rawEntryId: /color/5.0.2 + referrerJsonIds: + - 0 + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: '' + name: target-folder + peerDependencyMeta: {} + version: link:link-specifier/target-folder + displayText: has-symbols 1.0.2 + entryId: '' + entryPackageName: has-symbols + entryPackageVersion: 1.0.2 + entrySuffix: '' + jsonId: 7 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/has-symbols@1.0.2/node_modules/has-symbols + rawEntryId: /has-symbols/1.0.2 + referrerJsonIds: + - 2 + transitivePeerDependencies: [] +workspace: + pnpmLockfileFolder: '' + pnpmLockfilePath: pnpm-lock.yaml + workspaceRootFullPath: /repo +" +`; diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap new file mode 100644 index 00000000000..382a4991800 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lfxGraph-edge-cases-v5.4 loads a workspace 1`] = ` +"entries: + - dependencies: + - dependencyType: regular + entryId: /color/5.0.2 + name: color + peerDependencyMeta: {} + resolvedEntryJsonId: 6 + version: 5.0.2 + displayText: 'Project: duplicate (duplicate-1/duplicate)' + entryId: project:duplicate-1/duplicate + entryPackageName: duplicate (duplicate-1/duplicate) + entryPackageVersion: '' + entrySuffix: '' + jsonId: 0 + kind: 1 + packageJsonFolderPath: duplicate-1/duplicate + rawEntryId: duplicate-1/duplicate + referrerJsonIds: [] + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-string/2.1.2 + name: color-string + peerDependencyMeta: {} + resolvedEntryJsonId: 5 + version: 2.1.2 + displayText: 'Project: duplicate (duplicate-2/duplicate)' + entryId: project:duplicate-2/duplicate + entryPackageName: duplicate (duplicate-2/duplicate) + entryPackageVersion: '' + entrySuffix: '' + jsonId: 1 + kind: 1 + packageJsonFolderPath: duplicate-2/duplicate + rawEntryId: duplicate-2/duplicate + referrerJsonIds: [] + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /has-symbols/1.0.2 + name: has-symbols + peerDependencyMeta: {} + resolvedEntryJsonId: 7 + version: 1.0.2 + - dependencyType: regular + entryId: project:link-specifier/target-folder + name: target-folder + peerDependencyMeta: {} + version: link:../target-folder + displayText: 'Project: linker' + entryId: project:link-specifier/linker + entryPackageName: linker + entryPackageVersion: '' + entrySuffix: '' + jsonId: 2 + kind: 1 + packageJsonFolderPath: link-specifier/linker + rawEntryId: link-specifier/linker + referrerJsonIds: [] + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-name/2.0.2 + name: color-name + peerDependencyMeta: {} + resolvedEntryJsonId: 4 + version: 2.0.2 + displayText: color-convert 3.1.2 + entryId: '' + entryPackageName: color-convert + entryPackageVersion: 3.1.2 + entrySuffix: '' + jsonId: 3 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color-convert@3.1.2/node_modules/color-convert + rawEntryId: /color-convert/3.1.2 + referrerJsonIds: + - 6 + transitivePeerDependencies: [] + - dependencies: [] + displayText: color-name 2.0.2 + entryId: '' + entryPackageName: color-name + entryPackageVersion: 2.0.2 + entrySuffix: '' + jsonId: 4 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color-name@2.0.2/node_modules/color-name + rawEntryId: /color-name/2.0.2 + referrerJsonIds: + - 3 + - 5 + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-name/2.0.2 + name: color-name + peerDependencyMeta: {} + resolvedEntryJsonId: 4 + version: 2.0.2 + displayText: color-string 2.1.2 + entryId: '' + entryPackageName: color-string + entryPackageVersion: 2.1.2 + entrySuffix: '' + jsonId: 5 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color-string@2.1.2/node_modules/color-string + rawEntryId: /color-string/2.1.2 + referrerJsonIds: + - 1 + - 6 + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: /color-convert/3.1.2 + name: color-convert + peerDependencyMeta: {} + resolvedEntryJsonId: 3 + version: 3.1.2 + - dependencyType: regular + entryId: /color-string/2.1.2 + name: color-string + peerDependencyMeta: {} + resolvedEntryJsonId: 5 + version: 2.1.2 + displayText: color 5.0.2 + entryId: '' + entryPackageName: color + entryPackageVersion: 5.0.2 + entrySuffix: '' + jsonId: 6 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/color@5.0.2/node_modules/color + rawEntryId: /color/5.0.2 + referrerJsonIds: + - 0 + transitivePeerDependencies: [] + - dependencies: + - dependencyType: regular + entryId: '' + name: target-folder + peerDependencyMeta: {} + version: link:link-specifier/target-folder + displayText: has-symbols 1.0.2 + entryId: '' + entryPackageName: has-symbols + entryPackageVersion: 1.0.2 + entrySuffix: '' + jsonId: 7 + kind: 2 + packageJsonFolderPath: node_modules/.pnpm/has-symbols@1.0.2/node_modules/has-symbols + rawEntryId: /has-symbols/1.0.2 + referrerJsonIds: + - 2 + transitivePeerDependencies: [] +workspace: + pnpmLockfileFolder: '' + pnpmLockfilePath: pnpm-lock.yaml + workspaceRootFullPath: /repo +" +`; diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap index f37d9721956..a79208cc8c9 100644 --- a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap @@ -2,166 +2,154 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` "entries: - - dependencies: [] - displayText: '' - entryId: '' - entryPackageName: '' - entryPackageVersion: '' - entrySuffix: '' - jsonId: 0 - kind: 1 - packageJsonFolderPath: '' - rawEntryId: . - referrerJsonIds: [] - transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/d + entryId: project:projects/d name: '@rushstack/d' peerDependencyMeta: {} - resolvedEntryJsonId: 4 + resolvedEntryJsonId: 3 version: link:../d displayText: 'Project: a' - entryId: project:./common/projects/a + entryId: project:projects/a entryPackageName: a entryPackageVersion: '' entrySuffix: '' - jsonId: 1 + jsonId: 0 kind: 1 - packageJsonFolderPath: ./common/projects/a + packageJsonFolderPath: projects/a rawEntryId: ../../projects/a referrerJsonIds: [] transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/d + entryId: project:projects/d name: '@rushstack/d' peerDependencyMeta: {} - resolvedEntryJsonId: 4 + resolvedEntryJsonId: 3 version: link:../d - dependencyType: regular entryId: /@rushstack/n/2.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 12 + resolvedEntryJsonId: 11 version: 2.0.0 displayText: 'Project: b' - entryId: project:./common/projects/b + entryId: project:projects/b entryPackageName: b entryPackageVersion: '' entrySuffix: '' - jsonId: 2 + jsonId: 1 kind: 1 - packageJsonFolderPath: ./common/projects/b + packageJsonFolderPath: projects/b rawEntryId: ../../projects/b referrerJsonIds: [] transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/e + entryId: project:projects/e name: '@rushstack/e' peerDependencyMeta: {} - resolvedEntryJsonId: 5 + resolvedEntryJsonId: 4 version: link:../e - dependencyType: regular entryId: /@rushstack/k/1.0.0_@rushstack+m@1.0.0 name: '@rushstack/k' peerDependencyMeta: {} - resolvedEntryJsonId: 7 + resolvedEntryJsonId: 6 version: 1.0.0_@rushstack+m@1.0.0 - dependencyType: regular entryId: /@rushstack/m/1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 11 + resolvedEntryJsonId: 10 version: 1.0.0 displayText: 'Project: c' - entryId: project:./common/projects/c + entryId: project:projects/c entryPackageName: c entryPackageVersion: '' entrySuffix: '' - jsonId: 3 + jsonId: 2 kind: 1 - packageJsonFolderPath: ./common/projects/c + packageJsonFolderPath: projects/c rawEntryId: ../../projects/c referrerJsonIds: [] transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/e + entryId: project:projects/e name: '@rushstack/e' peerDependencyMeta: {} - resolvedEntryJsonId: 5 + resolvedEntryJsonId: 4 version: link:../e - dependencyType: regular entryId: /@rushstack/j/1.0.0_@rushstack+n@2.0.0 name: '@rushstack/j' peerDependencyMeta: {} - resolvedEntryJsonId: 6 + resolvedEntryJsonId: 5 version: 1.0.0_@rushstack+n@2.0.0 - dependencyType: regular entryId: /@rushstack/n/2.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 12 + resolvedEntryJsonId: 11 version: 2.0.0 displayText: 'Project: d' - entryId: project:./common/projects/d + entryId: project:projects/d entryPackageName: d entryPackageVersion: '' entrySuffix: '' - jsonId: 4 + jsonId: 3 kind: 1 - packageJsonFolderPath: ./common/projects/d + packageJsonFolderPath: projects/d rawEntryId: ../../projects/d referrerJsonIds: + - 0 - 1 - - 2 transitivePeerDependencies: [] - dependencies: - dependencyType: regular entryId: /@rushstack/n/3.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 13 + resolvedEntryJsonId: 12 version: 3.0.0 displayText: 'Project: e' - entryId: project:./common/projects/e + entryId: project:projects/e entryPackageName: e entryPackageVersion: '' entrySuffix: '' - jsonId: 5 + jsonId: 4 kind: 1 - packageJsonFolderPath: ./common/projects/e + packageJsonFolderPath: projects/e rawEntryId: ../../projects/e referrerJsonIds: + - 2 - 3 - - 4 transitivePeerDependencies: [] - dependencies: - dependencyType: regular entryId: /@rushstack/k/1.0.0_wxpgugna4ivthu7yyu4fmciltu name: '@rushstack/k' peerDependencyMeta: {} - resolvedEntryJsonId: 8 + resolvedEntryJsonId: 7 version: 1.0.0_wxpgugna4ivthu7yyu4fmciltu - dependencyType: regular entryId: /@rushstack/m/1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 11 + resolvedEntryJsonId: 10 version: 1.0.0 displayText: '@rushstack/j 1.0.0 (@rushstack+n@2.0.0)' entryId: '' entryPackageName: '@rushstack/j' entryPackageVersion: 1.0.0 entrySuffix: '@rushstack+n@2.0.0' - jsonId: 6 + jsonId: 5 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+j@1.0.0_@rushstack+n@2.0.0/node_modules/@rushstack/j + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+j@1.0.0_@rushstack+n@2.0.0/node_modules/@rushstack/j rawEntryId: /@rushstack/j/1.0.0_@rushstack+n@2.0.0 referrerJsonIds: - - 4 + - 3 transitivePeerDependencies: - '@rushstack/n' - dependencies: @@ -169,19 +157,19 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryId: /@rushstack/l/1.0.0_@rushstack+m@1.0.0 name: '@rushstack/l' peerDependencyMeta: {} - resolvedEntryJsonId: 9 + resolvedEntryJsonId: 8 version: 1.0.0_@rushstack+m@1.0.0 displayText: '@rushstack/k 1.0.0 (@rushstack+m@1.0.0)' entryId: '' entryPackageName: '@rushstack/k' entryPackageVersion: 1.0.0 entrySuffix: '@rushstack+m@1.0.0' - jsonId: 7 + jsonId: 6 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+k@1.0.0_@rushstack+m@1.0.0/node_modules/@rushstack/k + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+k@1.0.0_@rushstack+m@1.0.0/node_modules/@rushstack/k rawEntryId: /@rushstack/k/1.0.0_@rushstack+m@1.0.0 referrerJsonIds: - - 3 + - 2 transitivePeerDependencies: - '@rushstack/m' - '@rushstack/n' @@ -190,20 +178,19 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryId: /@rushstack/l/1.0.0_wxpgugna4ivthu7yyu4fmciltu name: '@rushstack/l' peerDependencyMeta: {} - resolvedEntryJsonId: 10 + resolvedEntryJsonId: 9 version: 1.0.0_wxpgugna4ivthu7yyu4fmciltu displayText: '@rushstack/k 1.0.0 (wxpgugna4ivthu7yyu4fmciltu)' entryId: '' entryPackageName: '@rushstack/k' entryPackageVersion: 1.0.0 entrySuffix: wxpgugna4ivthu7yyu4fmciltu - jsonId: 8 + jsonId: 7 kind: 2 - packageJsonFolderPath: >- - common/temp/undefined/node_modules/.pnpm/@rushstack+k@1.0.0_wxpgugna4ivthu7yyu4fmciltu/node_modules/@rushstack/k + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+k@1.0.0_wxpgugna4ivthu7yyu4fmciltu/node_modules/@rushstack/k rawEntryId: /@rushstack/k/1.0.0_wxpgugna4ivthu7yyu4fmciltu referrerJsonIds: - - 6 + - 5 transitivePeerDependencies: - '@rushstack/m' - '@rushstack/n' @@ -212,7 +199,7 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryId: /@rushstack/m/1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 11 + resolvedEntryJsonId: 10 version: 1.0.0 - dependencyType: peer entryId: 'Peer: @rushstack/m' @@ -235,25 +222,25 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryPackageName: '@rushstack/l' entryPackageVersion: 1.0.0 entrySuffix: '@rushstack+m@1.0.0' - jsonId: 9 + jsonId: 8 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+l@1.0.0_@rushstack+m@1.0.0/node_modules/@rushstack/l + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+l@1.0.0_@rushstack+m@1.0.0/node_modules/@rushstack/l rawEntryId: /@rushstack/l/1.0.0_@rushstack+m@1.0.0 referrerJsonIds: - - 7 + - 6 transitivePeerDependencies: [] - dependencies: - dependencyType: regular entryId: /@rushstack/m/1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 11 + resolvedEntryJsonId: 10 version: 1.0.0 - dependencyType: regular entryId: /@rushstack/n/2.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 12 + resolvedEntryJsonId: 11 version: 2.0.0 - dependencyType: peer entryId: 'Peer: @rushstack/m' @@ -276,13 +263,12 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryPackageName: '@rushstack/l' entryPackageVersion: 1.0.0 entrySuffix: wxpgugna4ivthu7yyu4fmciltu - jsonId: 10 + jsonId: 9 kind: 2 - packageJsonFolderPath: >- - common/temp/undefined/node_modules/.pnpm/@rushstack+l@1.0.0_wxpgugna4ivthu7yyu4fmciltu/node_modules/@rushstack/l + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+l@1.0.0_wxpgugna4ivthu7yyu4fmciltu/node_modules/@rushstack/l rawEntryId: /@rushstack/l/1.0.0_wxpgugna4ivthu7yyu4fmciltu referrerJsonIds: - - 8 + - 7 transitivePeerDependencies: [] - dependencies: [] displayText: '@rushstack/m 1.0.0' @@ -290,15 +276,15 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryPackageName: '@rushstack/m' entryPackageVersion: 1.0.0 entrySuffix: '' - jsonId: 11 + jsonId: 10 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+m@1.0.0/node_modules/@rushstack/m + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+m@1.0.0/node_modules/@rushstack/m rawEntryId: /@rushstack/m/1.0.0 referrerJsonIds: - - 3 - - 6 + - 2 + - 5 + - 8 - 9 - - 10 transitivePeerDependencies: [] - dependencies: [] displayText: '@rushstack/n 2.0.0' @@ -306,14 +292,14 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryPackageName: '@rushstack/n' entryPackageVersion: 2.0.0 entrySuffix: '' - jsonId: 12 + jsonId: 11 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+n@2.0.0/node_modules/@rushstack/n + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+n@2.0.0/node_modules/@rushstack/n rawEntryId: /@rushstack/n/2.0.0 referrerJsonIds: - - 2 - - 4 - - 10 + - 1 + - 3 + - 9 transitivePeerDependencies: [] - dependencies: [] displayText: '@rushstack/n 3.0.0' @@ -321,18 +307,19 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` entryPackageName: '@rushstack/n' entryPackageVersion: 3.0.0 entrySuffix: '' - jsonId: 13 + jsonId: 12 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+n@3.0.0/node_modules/@rushstack/n + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+n@3.0.0/node_modules/@rushstack/n rawEntryId: /@rushstack/n/3.0.0 referrerJsonIds: - - 5 + - 4 transitivePeerDependencies: [] workspace: + pnpmLockfileFolder: common/temp pnpmLockfilePath: common/temp/pnpm-lock.yaml rushConfig: rushVersion: 5.83.3 subspaceName: '' - workspaceRootFolder: /repo + workspaceRootFullPath: /repo " `; diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap index a24bbbb326c..7f65a88ff52 100644 --- a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap @@ -2,204 +2,191 @@ exports[`lfxGraph-website-sample-1-v6.0 loads a workspace 1`] = ` "entries: - - dependencies: [] - displayText: '' - entryId: '' - entryPackageName: '' - entryPackageVersion: '' - entrySuffix: '' - jsonId: 0 - kind: 1 - packageJsonFolderPath: '' - rawEntryId: . - referrerJsonIds: [] - transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/d + entryId: project:projects/d name: '@rushstack/d' peerDependencyMeta: {} - resolvedEntryJsonId: 4 + resolvedEntryJsonId: 3 version: link:../d displayText: 'Project: a' - entryId: project:./common/projects/a + entryId: project:projects/a entryPackageName: a entryPackageVersion: '' entrySuffix: '' - jsonId: 1 + jsonId: 0 kind: 1 - packageJsonFolderPath: ./common/projects/a + packageJsonFolderPath: projects/a rawEntryId: ../../projects/a referrerJsonIds: [] transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/d + entryId: project:projects/d name: '@rushstack/d' peerDependencyMeta: {} - resolvedEntryJsonId: 4 + resolvedEntryJsonId: 3 version: link:../d - dependencyType: regular - entryId: /@rushstack/n/2.0.0 + entryId: /@rushstack/n@2.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 10 + resolvedEntryJsonId: 9 version: 2.0.0 displayText: 'Project: b' - entryId: project:./common/projects/b + entryId: project:projects/b entryPackageName: b entryPackageVersion: '' entrySuffix: '' - jsonId: 2 + jsonId: 1 kind: 1 - packageJsonFolderPath: ./common/projects/b + packageJsonFolderPath: projects/b rawEntryId: ../../projects/b referrerJsonIds: [] transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/e + entryId: project:projects/e name: '@rushstack/e' peerDependencyMeta: {} - resolvedEntryJsonId: 5 + resolvedEntryJsonId: 4 version: link:../e - dependencyType: regular - entryId: /@rushstack/k/1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + entryId: /@rushstack/k@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) name: '@rushstack/k' peerDependencyMeta: {} - resolvedEntryJsonId: 7 + resolvedEntryJsonId: 6 version: 1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) - dependencyType: regular - entryId: /@rushstack/m/1.0.0 + entryId: /@rushstack/m@1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 9 + resolvedEntryJsonId: 8 version: 1.0.0 displayText: 'Project: c' - entryId: project:./common/projects/c + entryId: project:projects/c entryPackageName: c entryPackageVersion: '' entrySuffix: '' - jsonId: 3 + jsonId: 2 kind: 1 - packageJsonFolderPath: ./common/projects/c + packageJsonFolderPath: projects/c rawEntryId: ../../projects/c referrerJsonIds: [] transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: project:./common/projects/e + entryId: project:projects/e name: '@rushstack/e' peerDependencyMeta: {} - resolvedEntryJsonId: 5 + resolvedEntryJsonId: 4 version: link:../e - dependencyType: regular - entryId: /@rushstack/j/1.0.0(@rushstack/n@2.0.0) + entryId: /@rushstack/j@1.0.0(@rushstack/n@2.0.0) name: '@rushstack/j' peerDependencyMeta: {} - resolvedEntryJsonId: 6 + resolvedEntryJsonId: 5 version: 1.0.0(@rushstack/n@2.0.0) - dependencyType: regular - entryId: /@rushstack/n/2.0.0 + entryId: /@rushstack/n@2.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 10 + resolvedEntryJsonId: 9 version: 2.0.0 displayText: 'Project: d' - entryId: project:./common/projects/d + entryId: project:projects/d entryPackageName: d entryPackageVersion: '' entrySuffix: '' - jsonId: 4 + jsonId: 3 kind: 1 - packageJsonFolderPath: ./common/projects/d + packageJsonFolderPath: projects/d rawEntryId: ../../projects/d referrerJsonIds: + - 0 - 1 - - 2 transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: /@rushstack/n/3.0.0 + entryId: /@rushstack/n@3.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 11 + resolvedEntryJsonId: 10 version: 3.0.0 displayText: 'Project: e' - entryId: project:./common/projects/e + entryId: project:projects/e entryPackageName: e entryPackageVersion: '' entrySuffix: '' - jsonId: 5 + jsonId: 4 kind: 1 - packageJsonFolderPath: ./common/projects/e + packageJsonFolderPath: projects/e rawEntryId: ../../projects/e referrerJsonIds: + - 2 - 3 - - 4 transitivePeerDependencies: [] - dependencies: - dependencyType: regular - entryId: /@rushstack/k/1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + entryId: /@rushstack/k@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) name: '@rushstack/k' peerDependencyMeta: {} - resolvedEntryJsonId: 7 + resolvedEntryJsonId: 6 version: 1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) - dependencyType: regular - entryId: /@rushstack/m/1.0.0 + entryId: /@rushstack/m@1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 9 + resolvedEntryJsonId: 8 version: 1.0.0 - displayText: '@rushstack/j/1.0.0(@rushstack n@2.0.0)' + displayText: '@rushstack/j 1.0.0 [@rushstack/n@2.0.0]' entryId: '' - entryPackageName: '@rushstack/j/1.0.0(@rushstack' - entryPackageVersion: n@2.0.0) - entrySuffix: '' - jsonId: 6 + entryPackageName: '@rushstack/j' + entryPackageVersion: 1.0.0 + entrySuffix: '@rushstack/n@2.0.0' + jsonId: 5 kind: 2 - packageJsonFolderPath: >- - common/temp/undefined/node_modules/.pnpm/@rushstack+j/1.0.0(@rushstack@n@2.0.0)/node_modules/@rushstack/j/1.0.0(@rushstack - rawEntryId: /@rushstack/j/1.0.0(@rushstack/n@2.0.0) + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+j@1.0.0_@rushstack+n@2.0.0/node_modules/@rushstack/j + rawEntryId: /@rushstack/j@1.0.0(@rushstack/n@2.0.0) referrerJsonIds: - - 4 + - 3 transitivePeerDependencies: - '@rushstack/n' - dependencies: - dependencyType: regular - entryId: /@rushstack/l/1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + entryId: /@rushstack/l@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) name: '@rushstack/l' peerDependencyMeta: {} - resolvedEntryJsonId: 8 + resolvedEntryJsonId: 7 version: 1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) - displayText: '@rushstack/k/1.0.0(@rushstack/m@1.0.0)(@rushstack n@2.0.0)' + displayText: '@rushstack/k 1.0.0 [@rushstack/m@1.0.0; @rushstack/n@2.0.0]' entryId: '' - entryPackageName: '@rushstack/k/1.0.0(@rushstack/m@1.0.0)(@rushstack' - entryPackageVersion: n@2.0.0) - entrySuffix: '' - jsonId: 7 + entryPackageName: '@rushstack/k' + entryPackageVersion: 1.0.0 + entrySuffix: '@rushstack/m@1.0.0; @rushstack/n@2.0.0' + jsonId: 6 kind: 2 packageJsonFolderPath: >- - common/temp/undefined/node_modules/.pnpm/@rushstack+k/1.0.0(@rushstack/m@1.0.0)(@rushstack@n@2.0.0)/node_modules/@rushstack/k/1.0.0(@rushstack/m@1.0.0)(@rushstack - rawEntryId: /@rushstack/k/1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + common/temp/node_modules/.pnpm/@rushstack+k@1.0.0_@rushstack+m@1.0.0_@rushstack+n@2.0.0/node_modules/@rushstack/k + rawEntryId: /@rushstack/k@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) referrerJsonIds: - - 3 - - 6 + - 2 + - 5 transitivePeerDependencies: - '@rushstack/m' - '@rushstack/n' - dependencies: - dependencyType: regular - entryId: /@rushstack/m/1.0.0 + entryId: /@rushstack/m@1.0.0 name: '@rushstack/m' peerDependencyMeta: {} - resolvedEntryJsonId: 9 + resolvedEntryJsonId: 8 version: 1.0.0 - dependencyType: regular - entryId: /@rushstack/n/2.0.0 + entryId: /@rushstack/n@2.0.0 name: '@rushstack/n' peerDependencyMeta: {} - resolvedEntryJsonId: 10 + resolvedEntryJsonId: 9 version: 2.0.0 - dependencyType: peer entryId: 'Peer: @rushstack/m' @@ -217,18 +204,18 @@ exports[`lfxGraph-website-sample-1-v6.0 loads a workspace 1`] = ` optional: true version: ^2.0.0 version: ^2.0.0 - displayText: '@rushstack/l/1.0.0(@rushstack/m@1.0.0)(@rushstack n@2.0.0)' + displayText: '@rushstack/l 1.0.0 [@rushstack/m@1.0.0; @rushstack/n@2.0.0]' entryId: '' - entryPackageName: '@rushstack/l/1.0.0(@rushstack/m@1.0.0)(@rushstack' - entryPackageVersion: n@2.0.0) - entrySuffix: '' - jsonId: 8 + entryPackageName: '@rushstack/l' + entryPackageVersion: 1.0.0 + entrySuffix: '@rushstack/m@1.0.0; @rushstack/n@2.0.0' + jsonId: 7 kind: 2 packageJsonFolderPath: >- - common/temp/undefined/node_modules/.pnpm/@rushstack+l/1.0.0(@rushstack/m@1.0.0)(@rushstack@n@2.0.0)/node_modules/@rushstack/l/1.0.0(@rushstack/m@1.0.0)(@rushstack - rawEntryId: /@rushstack/l/1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) + common/temp/node_modules/.pnpm/@rushstack+l@1.0.0_@rushstack+m@1.0.0_@rushstack+n@2.0.0/node_modules/@rushstack/l + rawEntryId: /@rushstack/l@1.0.0(@rushstack/m@1.0.0)(@rushstack/n@2.0.0) referrerJsonIds: - - 7 + - 6 transitivePeerDependencies: [] - dependencies: [] displayText: '@rushstack/m 1.0.0' @@ -236,14 +223,14 @@ exports[`lfxGraph-website-sample-1-v6.0 loads a workspace 1`] = ` entryPackageName: '@rushstack/m' entryPackageVersion: 1.0.0 entrySuffix: '' - jsonId: 9 + jsonId: 8 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+m@1.0.0/node_modules/@rushstack/m - rawEntryId: /@rushstack/m/1.0.0 + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+m@1.0.0/node_modules/@rushstack/m + rawEntryId: /@rushstack/m@1.0.0 referrerJsonIds: - - 3 - - 6 - - 8 + - 2 + - 5 + - 7 transitivePeerDependencies: [] - dependencies: [] displayText: '@rushstack/n 2.0.0' @@ -251,14 +238,14 @@ exports[`lfxGraph-website-sample-1-v6.0 loads a workspace 1`] = ` entryPackageName: '@rushstack/n' entryPackageVersion: 2.0.0 entrySuffix: '' - jsonId: 10 + jsonId: 9 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+n@2.0.0/node_modules/@rushstack/n - rawEntryId: /@rushstack/n/2.0.0 + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+n@2.0.0/node_modules/@rushstack/n + rawEntryId: /@rushstack/n@2.0.0 referrerJsonIds: - - 2 - - 4 - - 8 + - 1 + - 3 + - 7 transitivePeerDependencies: [] - dependencies: [] displayText: '@rushstack/n 3.0.0' @@ -266,18 +253,19 @@ exports[`lfxGraph-website-sample-1-v6.0 loads a workspace 1`] = ` entryPackageName: '@rushstack/n' entryPackageVersion: 3.0.0 entrySuffix: '' - jsonId: 11 + jsonId: 10 kind: 2 - packageJsonFolderPath: common/temp/undefined/node_modules/.pnpm/@rushstack+n@3.0.0/node_modules/@rushstack/n - rawEntryId: /@rushstack/n/3.0.0 + packageJsonFolderPath: common/temp/node_modules/.pnpm/@rushstack+n@3.0.0/node_modules/@rushstack/n + rawEntryId: /@rushstack/n@3.0.0 referrerJsonIds: - - 5 + - 4 transitivePeerDependencies: [] workspace: + pnpmLockfileFolder: common/temp pnpmLockfilePath: common/temp/pnpm-lock.yaml rushConfig: rushVersion: 5.158.1 subspaceName: '' - workspaceRootFolder: /repo + workspaceRootFullPath: /repo " `; diff --git a/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v5.4.yaml b/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v5.4.yaml new file mode 100644 index 00000000000..4bf1414f29b --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v5.4.yaml @@ -0,0 +1,72 @@ +lockfileVersion: 5.4 + +importers: + duplicate-1/duplicate: + specifiers: + color: ^5.0.2 + dependencies: + color: 5.0.2 + + duplicate-2/duplicate: + specifiers: + color-string: ^2.1.2 + dependencies: + color-string: 2.1.2 + + link-specifier/linker: + specifiers: + has-symbols: 1.0.2 + target-folder: link:../target-folder + dependencies: + has-symbols: 1.0.2 + target-folder: link:../target-folder + +packages: + /color-convert/3.1.2: + resolution: + { + integrity: sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg== + } + engines: { node: '>=14.6' } + dependencies: + color-name: 2.0.2 + dev: false + + /color-name/2.0.2: + resolution: + { + integrity: sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A== + } + engines: { node: '>=12.20' } + dev: false + + /color-string/2.1.2: + resolution: + { + integrity: sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA== + } + engines: { node: '>=18' } + dependencies: + color-name: 2.0.2 + dev: false + + /color/5.0.2: + resolution: + { + integrity: sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA== + } + engines: { node: '>=18' } + dependencies: + color-convert: 3.1.2 + color-string: 2.1.2 + dev: false + + /has-symbols/1.0.2: + resolution: + { + integrity: sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + } + engines: { node: '>= 0.4' } + dependencies: + target-folder: link:link-specifier/target-folder + dev: false diff --git a/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v6.0.yaml b/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v6.0.yaml new file mode 100644 index 00000000000..47d4bd09ffe --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/pnpm-lock-v6.0.yaml @@ -0,0 +1,77 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + duplicate-1/duplicate: + dependencies: + color: + specifier: ^5.0.2 + version: 5.0.2 + + duplicate-2/duplicate: + dependencies: + color-string: + specifier: ^2.1.2 + version: 2.1.2 + + link-specifier/linker: + dependencies: + has-symbols: + specifier: 1.0.2 + version: 1.0.2 + target-folder: + specifier: link:../target-folder + version: link:../target-folder + +packages: + /color-convert@3.1.2: + resolution: + { + integrity: sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg== + } + engines: { node: '>=14.6' } + dependencies: + color-name: 2.0.2 + dev: false + + /color-name@2.0.2: + resolution: + { + integrity: sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A== + } + engines: { node: '>=12.20' } + dev: false + + /color-string@2.1.2: + resolution: + { + integrity: sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA== + } + engines: { node: '>=18' } + dependencies: + color-name: 2.0.2 + dev: false + + /color@5.0.2: + resolution: + { + integrity: sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA== + } + engines: { node: '>=18' } + dependencies: + color-convert: 3.1.2 + color-string: 2.1.2 + dev: false + + /has-symbols@1.0.2: + resolution: + { + integrity: sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + } + engines: { node: '>= 0.4' } + dependencies: + target-folder: link:link-specifier/target-folder + dev: false diff --git a/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/website-sample-1.md b/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/website-sample-1.md new file mode 100644 index 00000000000..ff328176fab --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/fixtures/edge-cases/website-sample-1.md @@ -0,0 +1,4 @@ +# fixtures/edge-cases + +This test fixture is a PNPM workspace crafted to reproduce interesting edge cases in the `lfxGraphLoader` algorithm. + diff --git a/apps/lockfile-explorer/src/graph/test/graphTestHelpers.ts b/apps/lockfile-explorer/src/graph/test/graphTestHelpers.ts index 52583dec6c2..74d99eb35de 100644 --- a/apps/lockfile-explorer/src/graph/test/graphTestHelpers.ts +++ b/apps/lockfile-explorer/src/graph/test/graphTestHelpers.ts @@ -23,8 +23,8 @@ export async function loadAndSerializeLFxGraphAsync(options: { FIXTURES_FOLDER + options.lockfilePathUnderFixtures, { convertLineEndings: NewlineKind.Lf } ); - const lockfileObject = yaml.load(lockfileYaml) as lfxGraphLoader.ILockfilePackageType; - const graph: LfxGraph = lfxGraphLoader.generateLockfileGraph(options.workspace, lockfileObject); + const lockfileObject = yaml.load(lockfileYaml); + const graph: LfxGraph = lfxGraphLoader.generateLockfileGraph(lockfileObject, options.workspace); const serializedObject: IJsonLfxGraph = lfxGraphSerializer.serializeToJson(graph); const serializedYaml: string = yaml.dump(serializedObject, { noRefs: true, diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts new file mode 100644 index 00000000000..1a362d47c73 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IJsonLfxWorkspace } from '../../../build/lfx-shared'; + +import * as graphTestHelpers from './graphTestHelpers'; + +export const workspace: IJsonLfxWorkspace = { + workspaceRootFullPath: '/repo', + pnpmLockfilePath: 'pnpm-lock.yaml', + pnpmLockfileFolder: '', + rushConfig: undefined +}; + +describe('lfxGraph-edge-cases-v5.4', () => { + it('loads a workspace', async () => { + const serializedYaml: string = await graphTestHelpers.loadAndSerializeLFxGraphAsync({ + lockfilePathUnderFixtures: '/edge-cases/pnpm-lock-v5.4.yaml', + workspace: workspace + }); + expect(serializedYaml).toMatchSnapshot(); + }); +}); diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts new file mode 100644 index 00000000000..1a362d47c73 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IJsonLfxWorkspace } from '../../../build/lfx-shared'; + +import * as graphTestHelpers from './graphTestHelpers'; + +export const workspace: IJsonLfxWorkspace = { + workspaceRootFullPath: '/repo', + pnpmLockfilePath: 'pnpm-lock.yaml', + pnpmLockfileFolder: '', + rushConfig: undefined +}; + +describe('lfxGraph-edge-cases-v5.4', () => { + it('loads a workspace', async () => { + const serializedYaml: string = await graphTestHelpers.loadAndSerializeLFxGraphAsync({ + lockfilePathUnderFixtures: '/edge-cases/pnpm-lock-v5.4.yaml', + workspace: workspace + }); + expect(serializedYaml).toMatchSnapshot(); + }); +}); diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts index 3ca5deda4b8..af8dc57b59d 100644 --- a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts @@ -6,8 +6,9 @@ import type { IJsonLfxWorkspace } from '../../../build/lfx-shared'; import * as graphTestHelpers from './graphTestHelpers'; export const workspace: IJsonLfxWorkspace = { - workspaceRootFolder: '/repo', + workspaceRootFullPath: '/repo', pnpmLockfilePath: 'common/temp/pnpm-lock.yaml', + pnpmLockfileFolder: 'common/temp', rushConfig: { rushVersion: '5.83.3', subspaceName: '' diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts index dd381d93a96..90231d947c4 100644 --- a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts @@ -6,8 +6,9 @@ import type { IJsonLfxWorkspace } from '../../../build/lfx-shared'; import * as graphTestHelpers from './graphTestHelpers'; export const workspace: IJsonLfxWorkspace = { - workspaceRootFolder: '/repo', + workspaceRootFullPath: '/repo', pnpmLockfilePath: 'common/temp/pnpm-lock.yaml', + pnpmLockfileFolder: 'common/temp', rushConfig: { rushVersion: '5.158.1', subspaceName: '' diff --git a/apps/lockfile-explorer/src/graph/test/lockfile.test.ts b/apps/lockfile-explorer/src/graph/test/lockfile.test.ts index 8b89f8de00d..7e1a92a090c 100644 --- a/apps/lockfile-explorer/src/graph/test/lockfile.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lockfile.test.ts @@ -8,7 +8,7 @@ import * as lfxGraphLoader from '../lfxGraphLoader'; describe('LockfileGeneration', () => { it('creates a valid bi-directional graph', () => { - const resolvedPackages = lfxGraphLoader.generateLockfileGraph(TEST_WORKSPACE, TEST_LOCKFILE).entries; + const resolvedPackages = lfxGraphLoader.generateLockfileGraph(TEST_LOCKFILE, TEST_WORKSPACE).entries; // Mapping of all the lockfile entries created by the lockfile const resolvedPackagesMap: { [key: string]: LfxGraphEntry } = {}; @@ -20,7 +20,7 @@ describe('LockfileGeneration', () => { // Ensure validity of the example lockfile entry expect(exampleLockfileImporter.rawEntryId).toBe('../../../apps/testApp1'); - expect(exampleLockfileImporter.entryId).toBe('project:./apps/testApp1'); + expect(exampleLockfileImporter.entryId).toBe('project:apps/testApp1'); // Test that dependencies are linked in the importer project expect(exampleLockfileImporter.dependencies.length).toBe(2); diff --git a/apps/lockfile-explorer/src/graph/test/lockfilePath.test.ts b/apps/lockfile-explorer/src/graph/test/lockfilePath.test.ts new file mode 100644 index 00000000000..008e3b848f1 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/lockfilePath.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as lockfilePath from '../lockfilePath'; + +describe('lockfilePath', () => { + it('getBaseNameOf', () => { + expect(lockfilePath.getBaseNameOf('/a/b/c/d')).toBe('d'); + expect(lockfilePath.getBaseNameOf('.')).toBe('.'); + expect(lockfilePath.getBaseNameOf('')).toBe(''); + + expect(() => lockfilePath.getParentOf('/a/')).toThrowError('has a trailing slash'); + }); + + it('getParentOf', () => { + expect(lockfilePath.getParentOf('a/b/c/d')).toBe('a/b/c'); + expect(lockfilePath.getParentOf('/a/b/c')).toBe('/a/b'); + expect(lockfilePath.getParentOf('/a/b')).toBe('/a'); + expect(lockfilePath.getParentOf('/a')).toBe('/'); + expect(lockfilePath.getParentOf('a')).toBe('.'); + + expect(() => lockfilePath.getParentOf('')).toThrowError('has no parent'); + expect(() => lockfilePath.getParentOf('/')).toThrowError('has no parent'); + expect(() => lockfilePath.getParentOf('.')).toThrowError('has no parent'); + expect(() => lockfilePath.getParentOf('/a/')).toThrowError('has a trailing slash'); + }); + + it('getAbsolute', () => { + expect(lockfilePath.getAbsolute('a/b/c', 'd/e')).toBe('a/b/c/d/e'); + expect(lockfilePath.getAbsolute('/a/b/c', 'd/e')).toBe('/a/b/c/d/e'); + expect(lockfilePath.getAbsolute('/a/b/c', '/d/e')).toBe('/d/e'); + expect(lockfilePath.getAbsolute('a/b/c', '../../f')).toBe('a/f'); + expect(lockfilePath.getAbsolute('a/b/c', '.././/f')).toBe('a/b/f'); + expect(lockfilePath.getAbsolute('a/b/c', '../../..')).toBe('.'); + expect(lockfilePath.getAbsolute('C:/a/b', '../d')).toBe('C:/a/d'); + + // Error case + expect(() => lockfilePath.getAbsolute('a/b/c', '../../../..')).toThrowError('goes above the root folder'); + + // Degenerate cases + expect(lockfilePath.getAbsolute('a/b/c/', 'd/')).toBe('a/b/c/d'); + expect(lockfilePath.getAbsolute('./../c', 'd')).toBe('./../c/d'); + expect(lockfilePath.getAbsolute('C:\\', '\\a')).toBe('C:\\/\\a'); + }); + + it('join', () => { + expect(lockfilePath.join('', 'a')).toBe('a'); + expect(lockfilePath.join('b', '')).toBe('b'); + expect(lockfilePath.join('a', 'b')).toBe('a/b'); + expect(lockfilePath.join('a/', 'b')).toBe('a/b'); + expect(lockfilePath.join('a', '/b')).toBe('a/b'); + expect(lockfilePath.join('a/', '/b')).toBe('a/b'); + + // Degenerate cases + expect(lockfilePath.join('a//', '/b')).toBe('a//b'); + }); +}); diff --git a/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts b/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts index 977cae423be..46ffc2c09db 100644 --- a/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts +++ b/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts @@ -8,25 +8,11 @@ import { TEST_WORKSPACE, TEST_LOCKFILE } from './testLockfile'; describe('serializeToJson', () => { it('serializes a simple graph', () => { - const graph = lfxGraphLoader.generateLockfileGraph(TEST_WORKSPACE, TEST_LOCKFILE); + const graph = lfxGraphLoader.generateLockfileGraph(TEST_LOCKFILE, TEST_WORKSPACE); expect(lfxGraphSerializer.serializeToJson(graph)).toMatchInlineSnapshot(` Object { "entries": Array [ - Object { - "dependencies": Array [], - "displayText": "", - "entryId": "", - "entryPackageName": "", - "entryPackageVersion": "", - "entrySuffix": "", - "jsonId": 0, - "kind": 1, - "packageJsonFolderPath": "", - "rawEntryId": ".", - "referrerJsonIds": Array [], - "transitivePeerDependencies": Array [], - }, Object { "dependencies": Array [ Object { @@ -38,7 +24,7 @@ Object { "optional": undefined, "version": undefined, }, - "resolvedEntryJsonId": 2, + "resolvedEntryJsonId": 1, "version": "1.7.1", }, Object { @@ -50,18 +36,18 @@ Object { "optional": undefined, "version": undefined, }, - "resolvedEntryJsonId": 3, + "resolvedEntryJsonId": 2, "version": "1.7.1", }, ], "displayText": "Project: testApp1", - "entryId": "project:./apps/testApp1", + "entryId": "project:apps/testApp1", "entryPackageName": "testApp1", "entryPackageVersion": "", "entrySuffix": "", - "jsonId": 1, + "jsonId": 0, "kind": 1, - "packageJsonFolderPath": "./apps/testApp1", + "packageJsonFolderPath": "apps/testApp1", "rawEntryId": "../../../apps/testApp1", "referrerJsonIds": Array [], "transitivePeerDependencies": Array [], @@ -73,12 +59,12 @@ Object { "entryPackageName": "@testPackage/core", "entryPackageVersion": "1.7.1", "entrySuffix": "", - "jsonId": 2, + "jsonId": 1, "kind": 2, - "packageJsonFolderPath": "common/temp/undefined/node_modules/.pnpm/@testPackage+core@1.7.1/node_modules/@testPackage/core", + "packageJsonFolderPath": "common/temp/my-subspace/node_modules/.pnpm/@testPackage+core@1.7.1/node_modules/@testPackage/core", "rawEntryId": "/@testPackage/core/1.7.1", "referrerJsonIds": Array [ - 1, + 0, ], "transitivePeerDependencies": Array [], }, @@ -89,27 +75,31 @@ Object { "entryPackageName": "@testPackage2/core", "entryPackageVersion": "1.7.1", "entrySuffix": "", - "jsonId": 3, + "jsonId": 2, "kind": 2, - "packageJsonFolderPath": "common/temp/undefined/node_modules/.pnpm/@testPackage2+core@1.7.1/node_modules/@testPackage2/core", + "packageJsonFolderPath": "common/temp/my-subspace/node_modules/.pnpm/@testPackage2+core@1.7.1/node_modules/@testPackage2/core", "rawEntryId": "/@testPackage2/core/1.7.1", "referrerJsonIds": Array [ - 1, + 0, ], "transitivePeerDependencies": Array [], }, ], "workspace": Object { - "pnpmLockfilePath": "/test/pnpm-lock.yaml", - "rushConfig": undefined, - "workspaceRootFolder": "/test", + "pnpmLockfileFolder": "common/temp/my-subspace", + "pnpmLockfilePath": "common/temp/my-subspace/pnpm-lock.yaml", + "rushConfig": Object { + "rushVersion": "0.0.0", + "subspaceName": "my-subspace", + }, + "workspaceRootFullPath": "/repo", }, } `); }); it('deserializes a simple graph', () => { - const originalGraph = lfxGraphLoader.generateLockfileGraph(TEST_WORKSPACE, TEST_LOCKFILE); + const originalGraph = lfxGraphLoader.generateLockfileGraph(TEST_LOCKFILE, TEST_WORKSPACE); const serialized: string = JSON.stringify( lfxGraphSerializer.serializeToJson(originalGraph), diff --git a/apps/lockfile-explorer/src/graph/test/testLockfile.ts b/apps/lockfile-explorer/src/graph/test/testLockfile.ts index 0ba13018fbc..88f898cbdb2 100644 --- a/apps/lockfile-explorer/src/graph/test/testLockfile.ts +++ b/apps/lockfile-explorer/src/graph/test/testLockfile.ts @@ -4,13 +4,17 @@ import type { IJsonLfxWorkspace } from '../../../build/lfx-shared'; export const TEST_WORKSPACE: IJsonLfxWorkspace = { - workspaceRootFolder: '/test', - pnpmLockfilePath: '/test/pnpm-lock.yaml', - rushConfig: undefined + workspaceRootFullPath: '/repo', + pnpmLockfilePath: 'common/temp/my-subspace/pnpm-lock.yaml', + pnpmLockfileFolder: 'common/temp/my-subspace', + rushConfig: { + rushVersion: '0.0.0', + subspaceName: 'my-subspace' + } }; export const TEST_LOCKFILE = { - lockfileVersion: 5.3, + lockfileVersion: 5.4, importers: { '.': { specifiers: {} diff --git a/apps/lockfile-explorer/src/utils/init.ts b/apps/lockfile-explorer/src/utils/init.ts index 73856ea7943..55f21779109 100644 --- a/apps/lockfile-explorer/src/utils/init.ts +++ b/apps/lockfile-explorer/src/utils/init.ts @@ -33,18 +33,20 @@ export const init = (options: { const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); const workspaceFolder: string = subspace.getSubspaceTempFolderPath(); - const pnpmLockfileLocation: string = path.resolve(workspaceFolder, 'pnpm-lock.yaml'); + const pnpmLockfileAbsolutePath: string = path.resolve(workspaceFolder, 'pnpm-lock.yaml'); + const pnpmLockfileRelativePath: string = path.relative(currentFolder, pnpmLockfileAbsolutePath); appState = { currentWorkingDirectory, appVersion, debugMode, lockfileExplorerProjectRoot, - pnpmLockfileLocation, + pnpmLockfileLocation: pnpmLockfileAbsolutePath, pnpmfileLocation: workspaceFolder + '/.pnpmfile.cjs', projectRoot: currentFolder, lfxWorkspace: { - workspaceRootFolder: currentFolder, - pnpmLockfilePath: Path.convertToSlashes(path.relative(currentFolder, pnpmLockfileLocation)), + workspaceRootFullPath: currentFolder, + pnpmLockfilePath: Path.convertToSlashes(pnpmLockfileRelativePath), + pnpmLockfileFolder: Path.convertToSlashes(path.dirname(pnpmLockfileRelativePath)), rushConfig: { rushVersion: rushConfiguration.rushConfigurationJson.rushVersion, subspaceName: subspaceName ?? '' @@ -62,8 +64,9 @@ export const init = (options: { pnpmfileLocation: currentFolder + '/.pnpmfile.cjs', projectRoot: currentFolder, lfxWorkspace: { - workspaceRootFolder: currentFolder, + workspaceRootFullPath: currentFolder, pnpmLockfilePath: Path.convertToSlashes(path.relative(currentFolder, pnpmLockPath)), + pnpmLockfileFolder: '', rushConfig: undefined } }; diff --git a/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes3_2025-09-16-11-01.json b/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes3_2025-09-16-11-01.json new file mode 100644 index 00000000000..7b288ff1b92 --- /dev/null +++ b/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes3_2025-09-16-11-01.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lockfile-explorer", + "comment": "Improve support for PNPM lockfile format V6.0", + "type": "patch" + } + ], + "packageName": "@rushstack/lockfile-explorer" +} \ No newline at end of file diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 9f344afc501..b2dde4e55ed 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -114,6 +114,10 @@ "name": "@pnpm/logger", "allowedCategories": [ "libraries" ] }, + { + "name": "@pnpm/types", + "allowedCategories": [ "libraries" ] + }, { "name": "@redis/client", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/default/common-versions.json b/common/config/subspaces/default/common-versions.json index bdf76be499b..080e192c513 100644 --- a/common/config/subspaces/default/common-versions.json +++ b/common/config/subspaces/default/common-versions.json @@ -78,6 +78,9 @@ * This design avoids unnecessary churn in this file. */ "allowedAlternativeVersions": { + // Allow Lockfile Explorer to support PNPM 9.x + // TODO: Remove this after Rush adds support for PNPM 9.x + "@pnpm/lockfile.types": ["1002.0.1"], "@typescript-eslint/parser": [ "~6.19.0" // Used by build-tests/eslint-7(-*)-test / build-tests/eslint-bulk-suppressions-test-legacy ], diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 3818f460807..a7daffa4a97 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -193,9 +193,6 @@ importers: ../../../apps/lockfile-explorer: dependencies: - '@lifaon/path': - specifier: ~2.1.0 - version: 2.1.0 '@microsoft/rush-lib': specifier: workspace:* version: link:../../libraries/rush-lib @@ -236,9 +233,12 @@ importers: specifier: ~5.1.0 version: 5.1.0 devDependencies: - '@pnpm/lockfile-types': - specifier: ^5.1.5 - version: 5.1.5 + '@pnpm/lockfile.types': + specifier: 1002.0.1 + version: 1002.0.1 + '@pnpm/types': + specifier: 1000.8.0 + version: 1000.8.0 '@rushstack/heft': specifier: workspace:* version: link:../heft @@ -10165,10 +10165,6 @@ packages: resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} dev: false - /@lifaon/path@2.1.0: - resolution: {integrity: sha512-E+eJpDdwenIQCaYMMuCnteR34qAvXtHhHKjZOPB+hK4+R1yGcmWLLAEl2aklxCHx6w5VCKc8imx9AT05FGHhBw==} - dev: false - /@mdx-js/loader@1.6.22(react@17.0.2): resolution: {integrity: sha512-9CjGwy595NaxAYp0hF9B/A0lH6C8Rms97e2JS9d3jVUtILn6pT5i5IV965ra3lIWc7Rs1GG1tBdVF7dCowYe6Q==} dependencies: @@ -10485,13 +10481,6 @@ packages: ramda: 0.27.2 dev: false - /@pnpm/lockfile-types@5.1.5: - resolution: {integrity: sha512-02FP0HynzX+2DcuPtuMy7PH+kLIC0pevAydAOK+zug2bwdlSLErlvSkc+4+3dw60eRWgUXUqyfO2eR/Ansdbng==} - engines: {node: '>=16.14'} - dependencies: - '@pnpm/types': 9.4.2 - dev: true - /@pnpm/lockfile.types@1.0.3: resolution: {integrity: sha512-A7vUWktnhDkrIs+WmXm7AdffJVyVYJpQUEouya/DYhB+Y+tQ3BXjZ6CV0KybqLgI/8AZErgCJqFxA0GJH6QDjA==} engines: {node: '>=18.12'} @@ -10499,6 +10488,15 @@ packages: '@pnpm/patching.types': 1.0.0 '@pnpm/types': 12.2.0 + /@pnpm/lockfile.types@1002.0.1: + resolution: {integrity: sha512-anzBtzb78rf2KRExS8R38v4nyiU7b9ZMUsyzRdWpo+rfCmLUupjIxvasVlDgsf5pV7tbcBPASOamQ2G5V8IGAQ==} + engines: {node: '>=18.12'} + dependencies: + '@pnpm/patching.types': 1000.1.0 + '@pnpm/resolver-base': 1005.0.1 + '@pnpm/types': 1000.8.0 + dev: true + /@pnpm/logger@4.0.0: resolution: {integrity: sha512-SIShw+k556e7S7tLZFVSIHjCdiVog1qWzcKW2RbLEHPItdisAFVNIe34kYd9fMSswTlSRLS/qRjw3ZblzWmJ9Q==} engines: {node: '>=12.17'} @@ -10520,6 +10518,11 @@ packages: resolution: {integrity: sha512-juCdQCC1USqLcOhVPl1tYReoTO9YH4fTullMnFXXcmpsDM7Dkn3tzuOQKC3oPoJ2ozv+0EeWWMtMGqn2+IM3pQ==} engines: {node: '>=18.12'} + /@pnpm/patching.types@1000.1.0: + resolution: {integrity: sha512-Zib2ysLctRnWM4KXXlljR44qSKwyEqYmLk+8VPBDBEK3l5Gp5mT3N4ix9E4qjYynvFqahumsxzOfxOYQhUGMGw==} + engines: {node: '>=18.12'} + dev: true + /@pnpm/read-modules-dir@2.0.3: resolution: {integrity: sha512-i9OgRvSlxrTS9a2oXokhDxvQzDtfqtsooJ9jaGoHkznue5aFCTSrNZFQ6M18o8hC03QWfnxaKi0BtOvNkKu2+A==} engines: {node: '>=10.13'} @@ -10555,11 +10558,23 @@ packages: strip-bom: 4.0.0 dev: false + /@pnpm/resolver-base@1005.0.1: + resolution: {integrity: sha512-NBha12KjFMKwaG1BWTCtgr/RprNQhXItCBkzc8jZuVU0itAHRQhEykexna9K8XjAtYxZ9rhvir0T5a7fTB23yQ==} + engines: {node: '>=18.12'} + dependencies: + '@pnpm/types': 1000.8.0 + dev: true + /@pnpm/types@1000.6.0: resolution: {integrity: sha512-6PsMNe98VKPGcg6LnXSW/LE3YfJ77nj+bPKiRjYRWAQLZ+xXjEQRaR0dAuyjCmchlv4wR/hpnMVRS21/fCod5w==} engines: {node: '>=18.12'} dev: false + /@pnpm/types@1000.8.0: + resolution: {integrity: sha512-yx86CGHHquWAI0GgKIuV/RnYewcf5fVFZemC45C/K2cX0uV8GB8TUP541ZrokWola2fZx5sn1vL7xzbceRZfoQ==} + engines: {node: '>=18.12'} + dev: true + /@pnpm/types@12.2.0: resolution: {integrity: sha512-5RtwWhX39j89/Tmyv2QSlpiNjErA357T/8r1Dkg+2lD3P7RuS7Xi2tChvmOC3VlezEFNcWnEGCOeKoGRkDuqFA==} engines: {node: '>=18.12'} @@ -10577,6 +10592,7 @@ packages: /@pnpm/types@9.4.2: resolution: {integrity: sha512-g1hcF8Nv4gd76POilz9gD4LITAPXOe5nX4ijgr8ixCbLQZfcpYiMfJ+C1RlMNRUDo8vhlNB4O3bUlxmT6EAQXA==} engines: {node: '>=16.14'} + dev: false /@pnpm/write-project-manifest@1.1.7: resolution: {integrity: sha512-OLkDZSqkA1mkoPNPvLFXyI6fb0enCuFji6Zfditi/CLAo9kmIhQFmEUDu4krSB8i908EljG8YwL5Xjxzm5wsWA==} diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index f13a9c6c431..280c8df4e79 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "3749a69d1b0594a63c7a5ad6628f2897dbc3247c", + "pnpmShrinkwrapHash": "260e89de9a23ec7f38ec7956133ae1097057004b", "preferredVersionsHash": "61cd419c533464b580f653eb5f5a7e27fe7055ca" } From 69c60b5cc973822bb01bfe48757468b1775f8e16 Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Wed, 17 Sep 2025 13:42:18 -0400 Subject: [PATCH 12/22] move allowOversubscription to command-line.json --- common/reviews/api/node-core-library.api.md | 2 +- common/reviews/api/rush-lib.api.md | 2 - libraries/node-core-library/src/Async.ts | 23 ++--- .../node-core-library/src/test/Async.test.ts | 97 ++++++++++--------- libraries/rush-lib/src/api/CommandLineJson.ts | 2 + .../src/api/RushProjectConfiguration.ts | 7 -- .../rush-lib/src/cli/RushCommandLineParser.ts | 1 + .../cli/scriptActions/PhasedScriptAction.ts | 4 + .../src/logic/operations/Operation.ts | 7 -- .../operations/OperationExecutionManager.ts | 5 + .../operations/WeightedOperationPlugin.ts | 3 - .../test/OperationExecutionManager.test.ts | 5 + .../src/schemas/command-line.schema.json | 12 +++ .../src/schemas/rush-project.schema.json | 4 - 14 files changed, 90 insertions(+), 84 deletions(-) diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index 76160b35239..b5d7827f474 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -237,6 +237,7 @@ export type FolderItem = nodeFs.Dirent; // @public export interface IAsyncParallelismOptions { + allowOversubscription?: boolean; concurrency?: number; weighted?: boolean; } @@ -669,7 +670,6 @@ export interface IWaitForExitWithStringOptions extends IWaitForExitOptions { // @public (undocumented) export interface IWeighted { - allowOversubscription?: boolean; weight: number; } diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 3562b51724c..50b24af3f22 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -690,7 +690,6 @@ export interface IOperationRunnerContext { // @alpha (undocumented) export interface IOperationSettings { allowCobuildWithoutCache?: boolean; - allowOversubscription?: boolean; dependsOnAdditionalFiles?: string[]; dependsOnEnvVars?: string[]; disableBuildCacheForOperation?: boolean; @@ -988,7 +987,6 @@ export class NpmOptionsConfiguration extends PackageManagerOptionsConfigurationB export class Operation { constructor(options: IOperationOptions); addDependency(dependency: Operation): void; - allowOversubscription: boolean; readonly associatedPhase: IPhase; readonly associatedProject: RushConfigurationProject; readonly consumers: ReadonlySet; diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 7c9c0c331bd..b057b8fffc6 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -14,8 +14,7 @@ export interface IAsyncParallelismOptions { /** * Optionally used with the {@link (Async:class).(mapAsync:1)}, {@link (Async:class).(mapAsync:2)} and * {@link (Async:class).(forEachAsync:1)}, and {@link (Async:class).(forEachAsync:2)} to limit the maximum - * number of concurrent promises to the specified number. Individual operations may exceed this - * limit based on their `allowOversubscription` property. + * number of concurrent promises to the specified number. */ concurrency?: number; @@ -24,6 +23,13 @@ export interface IAsyncParallelismOptions { * take up more or less than one concurrency unit. */ weighted?: boolean; + + /** + * Controls whether operations can start even if doing so would exceed the total concurrency limit. + * If true (default), will start operations even when they would exceed the limit. + * If false, waits until sufficient capacity is available. + */ + allowOversubscription?: boolean; } /** @@ -83,17 +89,9 @@ export interface IWeighted { * Must be a whole number greater than or equal to 0. */ weight: number; - - /** - * Controls whether this operation can start, even if doing so would exceed the total concurrency limit.. - * If true (default), will start the operation even when it would exceed the limit. - * If false, waits until sufficient capacity is available. - */ - allowOversubscription?: boolean; } interface IWeightedWrapper { - allowOversubscription?: boolean; element: TElement; weight: number; } @@ -114,7 +112,6 @@ function toWeightedIterator( const { value, done } = await iterator.next(); return { value: { - allowOversubscription: value?.allowOversubscription ?? true, element: value, weight: useWeights ? value?.weight : 1 }, @@ -249,7 +246,7 @@ export class Async { // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; - const allowOversubscription: boolean = currentIteratorValue.allowOversubscription ?? true; + const allowOversubscription: boolean = options?.allowOversubscription ?? true; if (!allowOversubscription && wouldExceedConcurrency) { // eslint-disable-next-line require-atomic-updates nextIterator = currentIteratorResult; @@ -338,7 +335,7 @@ export class Async { * number of concurrency units that can be in progress at once. The weight of each operation * determines how many concurrency units it takes up. For example, if the concurrency is 2 * and the first operation has a weight of 2, then only one more operation can be in progress. - * Operations may exceed the concurrency limit based on their `allowOversubscription` property. + * Operations may exceed the concurrency limit based on the `allowOversubscription` option. * * If `callback` throws a synchronous exception, or if it returns a promise that rejects, * then the loop stops immediately. Any remaining array items will be skipped, and diff --git a/libraries/node-core-library/src/test/Async.test.ts b/libraries/node-core-library/src/test/Async.test.ts index c06df289b87..76e991c6a97 100644 --- a/libraries/node-core-library/src/test/Async.test.ts +++ b/libraries/node-core-library/src/test/Async.test.ts @@ -4,7 +4,6 @@ import { Async, AsyncQueue } from '../Async'; interface INumberWithWeight { - allowOversubscription?: boolean; n: number; weight: number; } @@ -62,8 +61,8 @@ describe(Async.name, () => { it('respects concurrency limit with allowOversubscription=false in mapAsync', async () => { const array: INumberWithWeight[] = [ - { n: 1, weight: 2, allowOversubscription: false }, - { n: 2, weight: 2, allowOversubscription: false } + { n: 1, weight: 2 }, + { n: 2, weight: 2 } ]; let running = 0; @@ -78,7 +77,7 @@ describe(Async.name, () => { running--; return `result-${item.n}`; }, - { concurrency: 3, weighted: true } + { concurrency: 3, weighted: true, allowOversubscription: false } ); expect(result).toEqual(['result-1', 'result-2']); @@ -740,8 +739,7 @@ describe(Async.name, () => { const array: INumberWithWeight[] = Array.from({ length: numberOfTasks }, (v, i) => i).map((n) => ({ n, - weight, - allowOversubscription: false + weight })); const fn: (item: INumberWithWeight) => Promise = jest.fn(async () => { @@ -751,96 +749,101 @@ describe(Async.name, () => { running--; }); - await Async.forEachAsync(array, fn, { concurrency, weighted: true }); + await Async.forEachAsync(array, fn, { concurrency, weighted: true, allowOversubscription: false }); expect(fn).toHaveBeenCalledTimes(numberOfTasks); expect(maxRunning).toEqual(expectedConcurrency); } ); - it('handles mixed weights enforcing a strict concurrency limit', async () => { - let running = 0; - let maxRunning = 0; - const startOrder: number[] = []; + it('waits for a small and large operation to finish before scheduling more', async () => { + let running: number = 0; + let maxRunning: number = 0; const array: INumberWithWeight[] = [ - { n: 1, weight: 1, allowOversubscription: false }, - { n: 2, weight: 3, allowOversubscription: false }, - { n: 3, weight: 1, allowOversubscription: false }, - { n: 4, weight: 2, allowOversubscription: false } + { n: 1, weight: 1 }, + { n: 2, weight: 10 }, + { n: 3, weight: 1 }, + { n: 4, weight: 10 }, + { n: 5, weight: 1 }, + { n: 6, weight: 10 }, + { n: 7, weight: 1 }, + { n: 8, weight: 10 } ]; - const fn = jest.fn(async (item: INumberWithWeight) => { + const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { running++; - startOrder.push(item.n); - maxRunning = Math.max(maxRunning, running); await Async.sleepAsync(0); + maxRunning = Math.max(maxRunning, running); running--; }); - await Async.forEachAsync(array, fn, { concurrency: 4, weighted: true }); - - expect(fn).toHaveBeenCalledTimes(4); - expect(maxRunning).toEqual(2); // Max should be limited by weight 3 task + await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true, allowOversubscription: false }); + expect(fn).toHaveBeenCalledTimes(8); + expect(maxRunning).toEqual(1); }); - it('waits for a small and large operation to finish before scheduling more', async () => { + it('handles operation with mixed weights', async () => { + const concurrency: number = 3; let running: number = 0; let maxRunning: number = 0; + const taskToMaxConcurrency: Record = {}; const array: INumberWithWeight[] = [ - { n: 1, weight: 1, allowOversubscription: false }, - { n: 2, weight: 10, allowOversubscription: false }, - { n: 3, weight: 1, allowOversubscription: false }, - { n: 4, weight: 10, allowOversubscription: false }, - { n: 5, weight: 1, allowOversubscription: false }, - { n: 6, weight: 10, allowOversubscription: false }, - { n: 7, weight: 1, allowOversubscription: false }, - { n: 8, weight: 10, allowOversubscription: false } + { n: 1, weight: 1 }, + { n: 2, weight: 2 }, + { n: 3, weight: concurrency }, + { n: 4, weight: 1 }, + { n: 5, weight: 1 } ]; const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { running++; + taskToMaxConcurrency[item.n] = running; await Async.sleepAsync(0); maxRunning = Math.max(maxRunning, running); running--; }); - await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true }); - expect(fn).toHaveBeenCalledTimes(8); - expect(maxRunning).toEqual(1); + await Async.forEachAsync(array, fn, { concurrency, weighted: true, allowOversubscription: false }); + expect(fn).toHaveBeenCalledTimes(5); + expect(maxRunning).toEqual(2); + + expect(taskToMaxConcurrency[1]).toEqual(1); // task 1 + expect(taskToMaxConcurrency[2]).toEqual(2); // task 1 + 2 + expect(taskToMaxConcurrency[3]).toEqual(1); // task 3 + expect(taskToMaxConcurrency[4]).toEqual(1); // task 4 + expect(taskToMaxConcurrency[5]).toEqual(2); // task 4 + 5 }); - it('handles operations with mixed values of allowOversubscription', async () => { - const concurrency: number = 3; + it('allows operations with weight 0 to be picked up when system is at max concurrency', async () => { let running: number = 0; let maxRunning: number = 0; const taskToMaxConcurrency: Record = {}; const array: INumberWithWeight[] = [ - { n: 1, weight: 1 }, // undefined allowOversubscription (should default to true) - { n: 2, weight: 2 }, // undefined allowOversubscription (should default to true) - { n: 3, weight: concurrency, allowOversubscription: false }, - { n: 4, weight: 1 }, // undefined allowOversubscription (should default to true) - { n: 5, weight: 1, allowOversubscription: true } + { n: 1, weight: 1 }, + { n: 2, weight: 0 }, + { n: 3, weight: 3 }, + { n: 4, weight: 1 } ]; const fn: (item: INumberWithWeight) => Promise = jest.fn(async (item) => { running++; taskToMaxConcurrency[item.n] = running; - await Async.sleepAsync(0); maxRunning = Math.max(maxRunning, running); + await Async.sleepAsync(0); running--; }); - await Async.forEachAsync(array, fn, { concurrency, weighted: true }); - expect(fn).toHaveBeenCalledTimes(5); + await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true, allowOversubscription: false }); + + expect(fn).toHaveBeenCalledTimes(4); expect(maxRunning).toEqual(2); - expect(taskToMaxConcurrency[1]).toEqual(1); // task 1 + 2 + expect(taskToMaxConcurrency[1]).toEqual(1); // task 1 expect(taskToMaxConcurrency[2]).toEqual(2); // task 1 + 2 - expect(taskToMaxConcurrency[3]).toEqual(1); // task 3 (allowOversubscription = false) + expect(taskToMaxConcurrency[3]).toEqual(2); // task 2 + 3 expect(taskToMaxConcurrency[4]).toEqual(1); // task 4 - expect(taskToMaxConcurrency[5]).toEqual(2); // task 4 + 5 }); }); }); diff --git a/libraries/rush-lib/src/api/CommandLineJson.ts b/libraries/rush-lib/src/api/CommandLineJson.ts index ba4f412176f..e6507e49633 100644 --- a/libraries/rush-lib/src/api/CommandLineJson.ts +++ b/libraries/rush-lib/src/api/CommandLineJson.ts @@ -23,6 +23,7 @@ export interface IBaseCommandJson { export interface IBulkCommandJson extends IBaseCommandJson { commandKind: 'bulk'; enableParallelism: boolean; + allowOversubscription?: boolean; ignoreDependencyOrder?: boolean; ignoreMissingScript?: boolean; incremental?: boolean; @@ -38,6 +39,7 @@ export interface IBulkCommandJson extends IBaseCommandJson { export interface IPhasedCommandWithoutPhasesJson extends IBaseCommandJson { commandKind: 'phased'; enableParallelism: boolean; + allowOversubscription?: boolean; incremental?: boolean; } diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index 669e16e6738..6a1bb5ad3a1 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -146,13 +146,6 @@ export interface IOperationSettings { * If true, this operation will never be skipped by the `--changed-projects-only` flag. */ ignoreChangedProjectsOnlyFlag?: boolean; - - /** - * Controls whether this operation can start, even if doing so would exceed the total concurrency limit.. - * If true (default), will start the operation even when it would exceed the limit. - * If false, waits until sufficient capacity is available. - */ - allowOversubscription?: boolean; } interface IOldRushProjectJson { diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index d21b637f931..feb3f052d6f 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -470,6 +470,7 @@ export class RushCommandLineParser extends CommandLineParser { enableParallelism: command.enableParallelism, incremental: command.incremental || false, disableBuildCache: command.disableBuildCache || false, + allowOversubscription: command.allowOversubscription ?? true, initialPhases: command.phases, originalPhases: command.originalPhases, diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 038e2386f31..906a3ca89ea 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -68,6 +68,7 @@ const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; */ export interface IPhasedScriptActionOptions extends IBaseScriptActionOptions { enableParallelism: boolean; + allowOversubscription: boolean; incremental: boolean; disableBuildCache: boolean; @@ -140,6 +141,7 @@ export class PhasedScriptAction extends BaseScriptAction i public readonly sessionAbortController: AbortController; private readonly _enableParallelism: boolean; + private readonly _allowOversubscription: boolean; private readonly _isIncrementalBuildAllowed: boolean; private readonly _disableBuildCache: boolean; private readonly _originalPhases: ReadonlySet; @@ -171,6 +173,7 @@ export class PhasedScriptAction extends BaseScriptAction i public constructor(options: IPhasedScriptActionOptions) { super(options); this._enableParallelism = options.enableParallelism; + this._allowOversubscription = options.allowOversubscription; this._isIncrementalBuildAllowed = options.incremental; this._disableBuildCache = options.disableBuildCache; this._originalPhases = options.originalPhases; @@ -583,6 +586,7 @@ export class PhasedScriptAction extends BaseScriptAction i quietMode: isQuietMode, debugMode: this.parser.isDebug, parallelism, + allowOversubscription: this._allowOversubscription, beforeExecuteOperationAsync: async (record: OperationExecutionRecord) => { return await this.hooks.beforeExecuteOperation.promise(record); }, diff --git a/libraries/rush-lib/src/logic/operations/Operation.ts b/libraries/rush-lib/src/logic/operations/Operation.ts index 4f93368da55..be3ec8ac5fc 100644 --- a/libraries/rush-lib/src/logic/operations/Operation.ts +++ b/libraries/rush-lib/src/logic/operations/Operation.ts @@ -107,13 +107,6 @@ export class Operation { */ public enabled: boolean; - /** - * Controls whether this operation can start, even if doing so would exceed the total concurrency limit.. - * If true (default), will start the operation even when it would exceed the limit. - * If false, waits until sufficient capacity is available. - */ - public allowOversubscription: boolean = true; - public constructor(options: IOperationOptions) { const { phase, project, runner, settings, logFilenameIdentifier } = options; this.associatedPhase = phase; diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts index b88211eabb7..b0f2a3e0cb2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts @@ -25,6 +25,7 @@ export interface IOperationExecutionManagerOptions { quietMode: boolean; debugMode: boolean; parallelism: number; + allowOversubscription: boolean; inputsSnapshot?: IInputsSnapshot; destination?: TerminalWritable; @@ -69,6 +70,7 @@ export class OperationExecutionManager { private readonly _executionRecords: Map; private readonly _quietMode: boolean; private readonly _parallelism: number; + private readonly _allowOversubscription: boolean; private readonly _totalOperations: number; private readonly _outputWritable: TerminalWritable; @@ -99,6 +101,7 @@ export class OperationExecutionManager { quietMode, debugMode, parallelism, + allowOversubscription, inputsSnapshot, beforeExecuteOperationAsync: beforeExecuteOperation, afterExecuteOperationAsync: afterExecuteOperation, @@ -112,6 +115,7 @@ export class OperationExecutionManager { this._hasAnyNonAllowedWarnings = false; this._hasAnyAborted = false; this._parallelism = parallelism; + this._allowOversubscription = allowOversubscription; this._beforeExecuteOperation = beforeExecuteOperation; this._afterExecuteOperation = afterExecuteOperation; @@ -304,6 +308,7 @@ export class OperationExecutionManager { } }, { + allowOversubscription: this._allowOversubscription, concurrency: maxParallelism, weighted: true } diff --git a/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts index 4738b77169c..89efe7ceb12 100644 --- a/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts @@ -43,9 +43,6 @@ function weightOperations( if (operationSettings?.weight) { operation.weight = operationSettings.weight; } - if (operationSettings?.allowOversubscription !== undefined) { - operation.allowOversubscription = operationSettings.allowOversubscription; - } } Async.validateWeightedIterable(operation); } diff --git a/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts index d86f2bc7f7b..b8168a8e232 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts @@ -103,6 +103,7 @@ describe(OperationExecutionManager.name, () => { quietMode: false, debugMode: false, parallelism: 1, + allowOversubscription: true, destination: mockWritable }; }); @@ -185,6 +186,7 @@ describe(OperationExecutionManager.name, () => { quietMode: false, debugMode: false, parallelism: 1, + allowOversubscription: true, destination: mockWritable } ); @@ -229,6 +231,7 @@ describe(OperationExecutionManager.name, () => { quietMode: false, debugMode: false, parallelism: 1, + allowOversubscription: true, destination: mockWritable } ); @@ -250,6 +253,7 @@ describe(OperationExecutionManager.name, () => { quietMode: false, debugMode: false, parallelism: 1, + allowOversubscription: true, destination: mockWritable }; }); @@ -287,6 +291,7 @@ describe(OperationExecutionManager.name, () => { quietMode: false, debugMode: false, parallelism: 1, + allowOversubscription: true, destination: mockWritable }; }); diff --git a/libraries/rush-lib/src/schemas/command-line.schema.json b/libraries/rush-lib/src/schemas/command-line.schema.json index dd2655f30b2..a51ec961a65 100644 --- a/libraries/rush-lib/src/schemas/command-line.schema.json +++ b/libraries/rush-lib/src/schemas/command-line.schema.json @@ -66,6 +66,11 @@ "description": "If true then this command can be run in parallel, i.e. executed simultaneously for multiple projects.", "type": "boolean" }, + "allowOversubscription": { + "title": "allowOversubscription", + "type": "boolean", + "description": "Controls whether operations can start even if doing so would exceed the total concurrency limit. This setting only applies when 'enableParallelism' is true and operations have a 'weight' property configured in their rush-project.json operationSettings. If true (default), operations will start even when they would exceed the limit. If false, operations wait until sufficient capacity is available." + }, "ignoreDependencyOrder": { "title": "ignoreDependencyOrder", "description": "Normally projects will be processed according to their dependency order: a given project will not start processing the command until all of its dependencies have completed. This restriction doesn't apply for certain operations, for example, a \"clean\" task that deletes output files. In this case you can set \"ignoreDependencyOrder\" to true to increase parallelism.", @@ -110,6 +115,7 @@ "shellCommand": { "$ref": "#/definitions/anything" }, "enableParallelism": { "$ref": "#/definitions/anything" }, + "allowOversubscription": { "$ref": "#/definitions/anything" }, "ignoreDependencyOrder": { "$ref": "#/definitions/anything" }, "ignoreMissingScript": { "$ref": "#/definitions/anything" }, "incremental": { "$ref": "#/definitions/anything" }, @@ -181,6 +187,11 @@ "description": "If true then this command can be run in parallel, i.e. executed simultaneously for multiple projects.", "type": "boolean" }, + "allowOversubscription": { + "title": "allowOversubscription", + "type": "boolean", + "description": "Controls whether operations can start even if doing so would exceed the total concurrency limit. This setting only applies when 'enableParallelism' is true and operations have a 'weight' property configured in their rush-project.json operationSettings. If true (default), operations will start even when they would exceed the limit. If false, operations wait until sufficient capacity is available." + }, "incremental": { "title": "Incremental", "description": "If true then this command's phases will be incremental and support caching.", @@ -253,6 +264,7 @@ "safeForSimultaneousRushProcesses": { "$ref": "#/definitions/anything" }, "enableParallelism": { "$ref": "#/definitions/anything" }, + "allowOversubscription": { "$ref": "#/definitions/anything" }, "incremental": { "$ref": "#/definitions/anything" }, "phases": { "$ref": "#/definitions/anything" }, "watchOptions": { "$ref": "#/definitions/anything" }, diff --git a/libraries/rush-lib/src/schemas/rush-project.schema.json b/libraries/rush-lib/src/schemas/rush-project.schema.json index d0db1b68f51..52883eaa6c1 100644 --- a/libraries/rush-lib/src/schemas/rush-project.schema.json +++ b/libraries/rush-lib/src/schemas/rush-project.schema.json @@ -110,10 +110,6 @@ "ignoreChangedProjectsOnlyFlag": { "type": "boolean", "description": "If true, this operation never be skipped by the `--changed-projects-only` flag. This is useful for projects that bundle code from other packages." - }, - "allowOversubscription": { - "type": "boolean", - "description": "If true, allows this operation to start even if doing so would exceed the maximum concurrency limit determined by the -p flag. If false, waits until sufficient capacity is available. Defaults to true to maintain the original behavior where the concurrency limit could be exceeded." } } } From 5b20bcf512f8e5f7e6aafca9ebf7d9f96592b4ab Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Wed, 17 Sep 2025 14:28:48 -0400 Subject: [PATCH 13/22] remove un-needed changes --- ...-concurrency-bug-fix_2025-09-16-19-06.json | 10 ---------- common/reviews/api/operation-graph.api.md | 5 +---- libraries/node-core-library/src/Async.ts | 14 +++----------- libraries/operation-graph/src/Operation.ts | 19 ++----------------- .../operations/WeightedOperationPlugin.ts | 2 +- 5 files changed, 7 insertions(+), 43 deletions(-) delete mode 100644 common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json diff --git a/common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json deleted file mode 100644 index 809d1e7764e..00000000000 --- a/common/changes/@rushstack/operation-graph/eb-concurrency-bug-fix_2025-09-16-19-06.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "changes": [ - { - "packageName": "@rushstack/operation-graph", - "comment": "add allowOversubscription option to prevent running tasks from exceeding concurrency", - "type": "minor" - } - ], - "packageName": "@rushstack/operation-graph" -} \ No newline at end of file diff --git a/common/reviews/api/operation-graph.api.md b/common/reviews/api/operation-graph.api.md index 7de4ee103ac..eac2ff25c29 100644 --- a/common/reviews/api/operation-graph.api.md +++ b/common/reviews/api/operation-graph.api.md @@ -7,7 +7,6 @@ /// import type { ITerminal } from '@rushstack/terminal'; -import { IWeighted } from '@rushstack/node-core-library'; // @beta export type CommandMessageFromHost = ICancelCommandMessage | IExitCommandMessage | IRunCommandMessage | ISyncCommandMessage; @@ -66,7 +65,6 @@ export interface IOperationExecutionOptions { - allowOversubscription?: boolean | undefined; group?: OperationGroupRecord | undefined; metadata?: TMetadata | undefined; name: string; @@ -150,11 +148,10 @@ export interface IWatchLoopState { } // @beta -export class Operation implements IOperationStates, IWeighted { +export class Operation implements IOperationStates { constructor(options: IOperationOptions); // (undocumented) addDependency(dependency: Operation): void; - allowOversubscription: boolean; readonly consumers: Set>; criticalPathLength: number | undefined; // (undocumented) diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index b057b8fffc6..5034d62e0d8 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -91,15 +91,10 @@ export interface IWeighted { weight: number; } -interface IWeightedWrapper { - element: TElement; - weight: number; -} - function toWeightedIterator( iterable: Iterable | AsyncIterable, useWeights?: boolean -): AsyncIterable> { +): AsyncIterable<{ element: TEntry; weight: number }> { const iterator: Iterator | AsyncIterator = ( (iterable as Iterable)[Symbol.iterator] || (iterable as AsyncIterable)[Symbol.asyncIterator] @@ -111,10 +106,7 @@ function toWeightedIterator( // The await is necessary here, but TS will complain - it's a false positive. const { value, done } = await iterator.next(); return { - value: { - element: value, - weight: useWeights ? value?.weight : 1 - }, + value: { element: value, weight: useWeights ? value?.weight : 1 }, done: !!done }; } @@ -199,7 +191,7 @@ export class Async { return result; } - private static async _forEachWeightedAsync>( + private static async _forEachWeightedAsync( iterable: AsyncIterable, callback: (entry: TReturn, arrayIndex: number) => Promise, options?: IAsyncParallelismOptions | undefined diff --git a/libraries/operation-graph/src/Operation.ts b/libraries/operation-graph/src/Operation.ts index f0c1d23833e..75528ce1295 100644 --- a/libraries/operation-graph/src/Operation.ts +++ b/libraries/operation-graph/src/Operation.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { InternalError, type IWeighted } from '@rushstack/node-core-library'; +import { InternalError } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { Stopwatch } from './Stopwatch'; @@ -41,13 +41,6 @@ export interface IOperationOptions - implements IOperationStates, IWeighted + implements IOperationStates { /** * A set of all dependencies which must be executed before this operation is complete. @@ -181,13 +174,6 @@ export class Operation Date: Wed, 17 Sep 2025 14:18:22 -0700 Subject: [PATCH 14/22] Update Node.js version in CI workflow (#5368) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7cfeeb2c71..b01cfc8f938 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,10 @@ jobs: - NodeVersion: 20.18.x NodeVersionDisplayName: 20 OS: ubuntu-latest - - NodeVersion: 22.12.x + - NodeVersion: 22.19.x NodeVersionDisplayName: 22 OS: ubuntu-latest - - NodeVersion: 22.12.x + - NodeVersion: 22.19.x NodeVersionDisplayName: 22 OS: windows-latest name: Node.js v${{ matrix.NodeVersionDisplayName }} (${{ matrix.OS }}) From 0cd8e4f074761b10672ff58aec1ed12287da0a08 Mon Sep 17 00:00:00 2001 From: David Michon Date: Wed, 17 Sep 2025 14:18:41 -0700 Subject: [PATCH 15/22] [rush-resolver-cache] Fix rush-lib reference (#5369) Co-authored-by: David Michon --- ...ix-rush-lib-external_2025-09-17-20-55.json | 10 ++++++++++ .../src/afterInstallAsync.ts | 8 +++++++- .../src/externals.ts | 19 ++++++++++--------- 3 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 common/changes/@microsoft/rush/fix-rush-lib-external_2025-09-17-20-55.json diff --git a/common/changes/@microsoft/rush/fix-rush-lib-external_2025-09-17-20-55.json b/common/changes/@microsoft/rush/fix-rush-lib-external_2025-09-17-20-55.json new file mode 100644 index 00000000000..05af2435f26 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-rush-lib-external_2025-09-17-20-55.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "[rush-resolver-cache] Ensure that the correct version of rush-lib is loaded when the global version doesn't match the repository version.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts index 72b9c631e76..47977f1ee7c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/afterInstallAsync.ts @@ -41,7 +41,7 @@ function getPlatformInfo(): IPlatformInfo { } const END_TOKEN: string = '/package.json":'; -const RESOLVER_CACHE_FILE_VERSION: 1 = 1; +const RESOLVER_CACHE_FILE_VERSION: 2 = 2; interface IExtendedResolverCacheFile extends IResolverCacheFile { /** @@ -94,6 +94,12 @@ export async function afterInstallAsync( throw new Error(`Failed to load shrinkwrap file: ${lockFilePath}`); } + if (!lockFile.hash) { + throw new Error( + `Shrinkwrap file does not have a hash. This indicates linking to an old version of Rush.` + ); + } + try { const oldCacheFileContent: string = await FileSystem.readFileAsync(cacheFilePath); const oldCache: IExtendedResolverCacheFile = JSON.parse(oldCacheFileContent); diff --git a/rush-plugins/rush-resolver-cache-plugin/src/externals.ts b/rush-plugins/rush-resolver-cache-plugin/src/externals.ts index 942c7d2dd89..4638cf2578c 100644 --- a/rush-plugins/rush-resolver-cache-plugin/src/externals.ts +++ b/rush-plugins/rush-resolver-cache-plugin/src/externals.ts @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type Module from 'node:module'; - import type { Operation as OperationType, OperationStatus as OperationStatusType } from '@rushstack/rush-sdk'; import type { PnpmShrinkwrapFile as PnpmShrinkwrapFileType } from '@rushstack/rush-sdk/lib/logic/pnpm/PnpmShrinkwrapFile'; import type * as rushSdkType from '@rushstack/rush-sdk'; @@ -22,27 +20,30 @@ export { Operation, OperationStatus }; // Support this plugin being webpacked. const req: typeof require = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : require; -const entryModule: Module | undefined = req.main; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getExternal(name: string): TResult { +const rushLibPath: string | undefined = process.env._RUSH_LIB_PATH; + +function importDependency(name: string): TResult { + if (!rushLibPath) { + throw new Error(`_RUSH_LIB_PATH variable is not set, cannot resolve rush-lib.`); + } const externalPath: string = req.resolve(name, { - paths: entryModule?.paths + paths: [rushLibPath] }); return req(externalPath); } // Private Rush APIs -export const { PnpmShrinkwrapFile } = getExternal< +export const { PnpmShrinkwrapFile } = importDependency< typeof import('@rushstack/rush-sdk/lib/logic/pnpm/PnpmShrinkwrapFile') >('@microsoft/rush-lib/lib/logic/pnpm/PnpmShrinkwrapFile'); // eslint-disable-next-line @typescript-eslint/no-redeclare export type PnpmShrinkwrapFile = PnpmShrinkwrapFileType; // Avoid bundling expensive stuff that's already part of Rush. -export const { Async } = getExternal( +export const { Async } = importDependency( `@rushstack/node-core-library/lib/Async` ); -export const { FileSystem } = getExternal( +export const { FileSystem } = importDependency( `@rushstack/node-core-library/lib/FileSystem` ); From ec3116b03490656924cceaf2937ebe5489de60ae Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:20:26 -0700 Subject: [PATCH 16/22] Update CI workflow: Run a second rush test step to verify build cache hits (#5370) --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b01cfc8f938..216794a5366 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,3 +95,7 @@ jobs: - name: Rush test (rush-lib) run: node ${{ github.workspace }}/repo-a/apps/rush/lib/start-dev.js test --verbose --production --timeline working-directory: repo-b + + - name: Rush test (rush-lib) again to verify build cache hits + run: node ${{ github.workspace }}/repo-a/apps/rush/lib/start-dev.js test --verbose --production --timeline + working-directory: repo-b From db50a615c65ed8b5eb72a99f49cf1ea991474e13 Mon Sep 17 00:00:00 2001 From: David Michon Date: Thu, 18 Sep 2025 11:46:54 -0700 Subject: [PATCH 17/22] [rush-serve] Support dependencies, aborting (#5367) * [rush-serve] Add dependencies to operations, support abort * Adjust "silent" field in socket --------- Co-authored-by: David Michon --- ...h-serve-dependencies_2025-09-16-23-56.json | 10 + .../src/RushProjectServeConfigFile.ts | 30 +- .../rush-serve-plugin/src/RushServePlugin.ts | 2 +- .../rush-serve-plugin/src/api.types.ts | 15 +- .../src/phasedCommandHandler.ts | 341 +--------------- .../tryEnableBuildStatusWebSocketServer.ts | 363 ++++++++++++++++++ rush-plugins/rush-serve-plugin/src/types.ts | 41 ++ 7 files changed, 441 insertions(+), 361 deletions(-) create mode 100644 common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json create mode 100644 rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts create mode 100644 rush-plugins/rush-serve-plugin/src/types.ts diff --git a/common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json b/common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json new file mode 100644 index 00000000000..d4fa959660d --- /dev/null +++ b/common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "[rush-serve-plugin] Support aborting execution via Web Socket. Include information about the dependencies of operations in messages to the client..", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts b/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts index 0126fcd5d30..cc7cb2d8220 100644 --- a/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts +++ b/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts @@ -8,35 +8,9 @@ import { Async } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { RigConfig } from '@rushstack/rig-package'; import type { RushConfigurationProject } from '@rushstack/rush-sdk'; -import rushProjectServeSchema from './schemas/rush-project-serve.schema.json'; - -export interface IRushProjectServeJson { - routing: IRoutingRuleJson[]; -} - -export interface IBaseRoutingRuleJson { - servePath: string; - immutable?: boolean; -} - -export interface IRoutingFolderRuleJson extends IBaseRoutingRuleJson { - projectRelativeFile: undefined; - projectRelativeFolder: string; -} - -export interface IRoutingFileRuleJson extends IBaseRoutingRuleJson { - projectRelativeFile: string; - projectRelativeFolder: undefined; -} -export type IRoutingRuleJson = IRoutingFileRuleJson | IRoutingFolderRuleJson; - -export interface IRoutingRule { - type: 'file' | 'folder'; - diskPath: string; - servePath: string; - immutable: boolean; -} +import rushProjectServeSchema from './schemas/rush-project-serve.schema.json'; +import type { IRushProjectServeJson, IRoutingRule } from './types'; export class RushServeConfiguration { private readonly _loader: ProjectConfigurationFile; diff --git a/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts b/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts index 08be11d31fb..1a4ab89a099 100644 --- a/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts +++ b/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import type { IRushPlugin, RushSession, RushConfiguration, IPhasedCommand } from '@rushstack/rush-sdk'; import { PLUGIN_NAME } from './constants'; -import type { IBaseRoutingRuleJson, IRoutingRule } from './RushProjectServeConfigFile'; +import type { IBaseRoutingRuleJson, IRoutingRule } from './types'; export interface IGlobalRoutingFolderRuleJson extends IBaseRoutingRuleJson { workspaceRelativeFile: undefined; diff --git a/rush-plugins/rush-serve-plugin/src/api.types.ts b/rush-plugins/rush-serve-plugin/src/api.types.ts index 2afcfc7a98d..99b5fcb012f 100644 --- a/rush-plugins/rush-serve-plugin/src/api.types.ts +++ b/rush-plugins/rush-serve-plugin/src/api.types.ts @@ -34,6 +34,11 @@ export interface IOperationInfo { */ name: string; + /** + * The names of the dependencies of the operation. + */ + dependencies: string[]; + /** * The npm package name of the containing Rush Project. */ @@ -151,6 +156,13 @@ export interface IWebSocketSyncCommandMessage { command: 'sync'; } +/** + * Message received from a WebSocket client to request abortion of the current execution pass. + */ +export interface IWebSocketAbortExecutionCommandMessage { + command: 'abort-execution'; +} + /** * Message received from a WebSocket client to request invalidation of one or more operations. */ @@ -162,7 +174,7 @@ export interface IWebSocketInvalidateCommandMessage { /** * The set of possible operation enabled states. */ -export type OperationEnabledState = 'never' | 'changed' | 'affected'; +export type OperationEnabledState = 'never' | 'changed' | 'affected' | 'default'; /** * Message received from a WebSocket client to change the enabled states of operations. @@ -177,5 +189,6 @@ export interface IWebSocketSetEnabledStatesCommandMessage { */ export type IWebSocketCommandMessage = | IWebSocketSyncCommandMessage + | IWebSocketAbortExecutionCommandMessage | IWebSocketInvalidateCommandMessage | IWebSocketSetEnabledStatesCommandMessage; diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index e949bf14490..b4805180bf2 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -2,62 +2,35 @@ // See LICENSE in the project root for license information. import { once } from 'node:events'; -import type { Server as HTTPSecureServer } from 'node:https'; -import http2, { type Http2SecureServer } from 'node:http2'; +import http2 from 'node:http2'; import type { AddressInfo } from 'node:net'; -import os from 'node:os'; import express, { type Application } from 'express'; import http2express from 'http2-express-bridge'; import cors from 'cors'; import compression from 'compression'; -import { WebSocketServer, type WebSocket, type MessageEvent } from 'ws'; import { CertificateManager, type ICertificate } from '@rushstack/debug-certificate-manager'; -import { AlreadyReportedError, Sort } from '@rushstack/node-core-library'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import { type ILogger, - type RushConfiguration, type RushConfigurationProject, - type RushSession, - type IPhasedCommand, type Operation, type ICreateOperationsContext, - type IOperationExecutionResult, - OperationStatus, - type IExecutionResult, - type ILogFilePaths, RushConstants } from '@rushstack/rush-sdk'; import { getProjectLogFolders } from '@rushstack/rush-sdk/lib/logic/operations/ProjectLogWritable'; import { type CommandLineIntegerParameter, CommandLineParameterKind } from '@rushstack/ts-command-line'; import { PLUGIN_NAME } from './constants'; -import { type IRoutingRule, RushServeConfiguration } from './RushProjectServeConfigFile'; - -import type { - IOperationInfo, - IWebSocketAfterExecuteEventMessage, - IWebSocketBeforeExecuteEventMessage, - IWebSocketEventMessage, - IWebSocketBatchStatusChangeEventMessage, - IWebSocketSyncEventMessage, - ReadableOperationStatus, - IWebSocketCommandMessage, - IRushSessionInfo, - ILogFileURLs, - OperationEnabledState -} from './api.types'; - -export interface IPhasedCommandHandlerOptions { - rushSession: RushSession; - rushConfiguration: RushConfiguration; - command: IPhasedCommand; - portParameterLongName: string | undefined; - logServePath: string | undefined; - globalRoutingRules: IRoutingRule[]; - buildStatusWebSocketPath: string | undefined; -} +import { RushServeConfiguration } from './RushProjectServeConfigFile'; +import type { IRoutingRule, IPhasedCommandHandlerOptions } from './types'; + +import { + getLogServePathForProject, + tryEnableBuildStatusWebSocketServer, + type WebSocketServerUpgrader +} from './tryEnableBuildStatusWebSocketServer'; export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions): Promise { const { rushSession, command, portParameterLongName, globalRoutingRules } = options; @@ -273,297 +246,3 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions command.hooks.waitingForChanges.tap(PLUGIN_NAME, logHost); } - -type WebSocketServerUpgrader = (server: Http2SecureServer) => void; - -/** - * - */ -function tryEnableBuildStatusWebSocketServer( - options: IPhasedCommandHandlerOptions -): WebSocketServerUpgrader | undefined { - const { buildStatusWebSocketPath } = options; - if (!buildStatusWebSocketPath) { - return; - } - - let operationStates: Map | undefined; - let buildStatus: ReadableOperationStatus = 'Ready'; - - const webSockets: Set = new Set(); - - // Map from OperationStatus enum values back to the names of the constants - const readableStatusFromStatus: { [K in OperationStatus]: ReadableOperationStatus } = { - [OperationStatus.Waiting]: 'Waiting', - [OperationStatus.Ready]: 'Ready', - [OperationStatus.Queued]: 'Queued', - [OperationStatus.Executing]: 'Executing', - [OperationStatus.Success]: 'Success', - [OperationStatus.SuccessWithWarning]: 'SuccessWithWarning', - [OperationStatus.Skipped]: 'Skipped', - [OperationStatus.FromCache]: 'FromCache', - [OperationStatus.Failure]: 'Failure', - [OperationStatus.Blocked]: 'Blocked', - [OperationStatus.NoOp]: 'NoOp', - [OperationStatus.Aborted]: 'Aborted' - }; - - const { logServePath } = options; - - function convertToLogFileUrls( - logFilePaths: ILogFilePaths | undefined, - packageName: string - ): ILogFileURLs | undefined { - if (!logFilePaths || !logServePath) { - return; - } - - const projectLogServePath: string = getLogServePathForProject(logServePath, packageName); - - const logFileUrls: ILogFileURLs = { - text: `${projectLogServePath}${logFilePaths.text.slice(logFilePaths.textFolder.length)}`, - error: `${projectLogServePath}${logFilePaths.error.slice(logFilePaths.textFolder.length)}`, - jsonl: `${projectLogServePath}${logFilePaths.jsonl.slice(logFilePaths.jsonlFolder.length)}` - }; - - return logFileUrls; - } - - /** - * Maps the internal Rush record down to a subset that is JSON-friendly and human readable. - */ - function convertToOperationInfo(record: IOperationExecutionResult): IOperationInfo | undefined { - const { operation } = record; - const { name, associatedPhase, associatedProject, runner, enabled } = operation; - - if (!name || !runner) { - return; - } - - const { packageName } = associatedProject; - - return { - name, - packageName, - phaseName: associatedPhase.name, - - enabled, - silent: record.silent, - noop: !!runner.isNoOp, - - status: readableStatusFromStatus[record.status], - startTime: record.stopwatch.startTime, - endTime: record.stopwatch.endTime, - - logFileURLs: convertToLogFileUrls(record.logFilePaths, packageName) - }; - } - - function convertToOperationInfoArray(records: Iterable): IOperationInfo[] { - const operations: IOperationInfo[] = []; - - for (const record of records) { - const info: IOperationInfo | undefined = convertToOperationInfo(record); - - if (info) { - operations.push(info); - } - } - - Sort.sortBy(operations, (x) => x.name); - return operations; - } - - function sendWebSocketMessage(message: IWebSocketEventMessage): void { - const stringifiedMessage: string = JSON.stringify(message); - for (const socket of webSockets) { - socket.send(stringifiedMessage); - } - } - - const { command } = options; - const sessionInfo: IRushSessionInfo = { - actionName: command.actionName, - repositoryIdentifier: getRepositoryIdentifier(options.rushConfiguration) - }; - - function sendSyncMessage(webSocket: WebSocket): void { - const syncMessage: IWebSocketSyncEventMessage = { - event: 'sync', - operations: convertToOperationInfoArray(operationStates?.values() ?? []), - sessionInfo, - status: buildStatus - }; - - webSocket.send(JSON.stringify(syncMessage)); - } - - const { hooks } = command; - - let invalidateOperation: ((operation: Operation, reason: string) => void) | undefined; - - const operationEnabledStates: Map = new Map(); - hooks.createOperations.tap( - { - name: PLUGIN_NAME, - stage: Infinity - }, - (operations: Set, context: ICreateOperationsContext) => { - const potentiallyAffectedOperations: Set = new Set(); - for (const operation of operations) { - const { associatedProject } = operation; - if (context.projectsInUnknownState.has(associatedProject)) { - potentiallyAffectedOperations.add(operation); - } - } - for (const operation of potentiallyAffectedOperations) { - for (const consumer of operation.consumers) { - potentiallyAffectedOperations.add(consumer); - } - - const { name } = operation; - const expectedState: OperationEnabledState | undefined = operationEnabledStates.get(name); - switch (expectedState) { - case 'affected': - operation.enabled = true; - break; - case 'never': - operation.enabled = false; - break; - case 'changed': - operation.enabled = context.projectsInUnknownState.has(operation.associatedProject); - break; - case undefined: - // Use the original value. - break; - } - } - - invalidateOperation = context.invalidateOperation; - - return operations; - } - ); - - hooks.beforeExecuteOperations.tap( - PLUGIN_NAME, - (operationsToExecute: Map): void => { - operationStates = operationsToExecute; - - const beforeExecuteMessage: IWebSocketBeforeExecuteEventMessage = { - event: 'before-execute', - operations: convertToOperationInfoArray(operationsToExecute.values()) - }; - buildStatus = 'Executing'; - sendWebSocketMessage(beforeExecuteMessage); - } - ); - - hooks.afterExecuteOperations.tap(PLUGIN_NAME, (result: IExecutionResult): void => { - buildStatus = readableStatusFromStatus[result.status]; - const infos: IOperationInfo[] = convertToOperationInfoArray(result.operationResults.values() ?? []); - const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { - event: 'after-execute', - operations: infos, - status: buildStatus - }; - sendWebSocketMessage(afterExecuteMessage); - }); - - const pendingStatusChanges: Map = new Map(); - let statusChangeTimeout: NodeJS.Immediate | undefined; - function sendBatchedStatusChange(): void { - statusChangeTimeout = undefined; - const infos: IOperationInfo[] = convertToOperationInfoArray(pendingStatusChanges.values()); - pendingStatusChanges.clear(); - const message: IWebSocketBatchStatusChangeEventMessage = { - event: 'status-change', - operations: infos - }; - sendWebSocketMessage(message); - } - - hooks.onOperationStatusChanged.tap(PLUGIN_NAME, (record: IOperationExecutionResult): void => { - pendingStatusChanges.set(record.operation, record); - if (!statusChangeTimeout) { - statusChangeTimeout = setImmediate(sendBatchedStatusChange); - } - }); - - const connector: WebSocketServerUpgrader = (server: Http2SecureServer) => { - const wss: WebSocketServer = new WebSocketServer({ - server: server as unknown as HTTPSecureServer, - path: buildStatusWebSocketPath - }); - wss.addListener('connection', (webSocket: WebSocket): void => { - webSockets.add(webSocket); - - sendSyncMessage(webSocket); - - webSocket.addEventListener('message', (ev: MessageEvent) => { - const parsedMessage: IWebSocketCommandMessage = JSON.parse(ev.data.toString()); - switch (parsedMessage.command) { - case 'sync': { - sendSyncMessage(webSocket); - break; - } - - case 'set-enabled-states': { - const { enabledStateByOperationName } = parsedMessage; - for (const [name, state] of Object.entries(enabledStateByOperationName)) { - operationEnabledStates.set(name, state); - } - break; - } - - case 'invalidate': { - const { operationNames } = parsedMessage; - const operationNameSet: Set = new Set(operationNames); - if (invalidateOperation && operationStates) { - for (const operation of operationStates.keys()) { - if (operationNameSet.has(operation.name)) { - invalidateOperation(operation, 'WebSocket'); - } - } - } - break; - } - - default: { - // Unknown message. Ignore. - } - } - }); - - webSocket.addEventListener( - 'close', - () => { - webSockets.delete(webSocket); - }, - { once: true } - ); - }); - }; - - return connector; -} - -function getRepositoryIdentifier(rushConfiguration: RushConfiguration): string { - const { env } = process; - const { CODESPACE_NAME: codespaceName, GITHUB_USER: githubUserName } = env; - - if (codespaceName) { - const usernamePrefix: string | undefined = githubUserName?.replace(/_|$/g, '-'); - const startIndex: number = - usernamePrefix && codespaceName.startsWith(usernamePrefix) ? usernamePrefix.length : 0; - const endIndex: number = codespaceName.lastIndexOf('-'); - const normalizedName: string = codespaceName.slice(startIndex, endIndex).replace(/-/g, ' '); - return `Codespace "${normalizedName}"`; - } - - return `${os.hostname()} - ${rushConfiguration.rushJsonFolder}`; -} - -function getLogServePathForProject(logServePath: string, packageName: string): string { - return `${logServePath}/${packageName}`; -} diff --git a/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts b/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts new file mode 100644 index 00000000000..1ecf5f10063 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Http2SecureServer } from 'node:http2'; +import type { Server as HTTPSecureServer } from 'node:https'; +import os from 'node:os'; + +import { type WebSocket, WebSocketServer, type MessageEvent } from 'ws'; + +import { Sort } from '@rushstack/node-core-library/lib/Sort'; +import { + type Operation, + type IOperationExecutionResult, + OperationStatus, + type ILogFilePaths, + type ICreateOperationsContext, + type IExecutionResult, + type RushConfiguration, + type IExecuteOperationsContext +} from '@rushstack/rush-sdk'; + +import type { + ReadableOperationStatus, + ILogFileURLs, + IOperationInfo, + IWebSocketEventMessage, + IRushSessionInfo, + IWebSocketSyncEventMessage, + OperationEnabledState, + IWebSocketBeforeExecuteEventMessage, + IWebSocketAfterExecuteEventMessage, + IWebSocketBatchStatusChangeEventMessage, + IWebSocketCommandMessage +} from './api.types'; +import { PLUGIN_NAME } from './constants'; +import type { IPhasedCommandHandlerOptions } from './types'; + +export type WebSocketServerUpgrader = (server: Http2SecureServer) => void; + +/** + * Returns a string that identifies the repository, based on the Rush configuration and environment. + * @param rushConfiguration - The Rush configuration object. + * @returns A string identifier for the repository. + */ +export function getRepositoryIdentifier(rushConfiguration: RushConfiguration): string { + const { env } = process; + const { CODESPACE_NAME: codespaceName, GITHUB_USER: githubUserName } = env; + + if (codespaceName) { + const usernamePrefix: string | undefined = githubUserName?.replace(/_|$/g, '-'); + const startIndex: number = + usernamePrefix && codespaceName.startsWith(usernamePrefix) ? usernamePrefix.length : 0; + const endIndex: number = codespaceName.lastIndexOf('-'); + const normalizedName: string = codespaceName.slice(startIndex, endIndex).replace(/-/g, ' '); + return `Codespace "${normalizedName}"`; + } + + return `${os.hostname()} - ${rushConfiguration.rushJsonFolder}`; +} + +/** + * @param logServePath - The base URL path where logs are being served. + * @param packageName - The npm package name of the project. + * @returns The base URL path for serving logs of the specified project. + */ +export function getLogServePathForProject(logServePath: string, packageName: string): string { + return `${logServePath}/${packageName}`; +} + +/** + * If the `buildStatusWebSocketPath` option is configured, this function returns a `WebSocketServerUpgrader` callback + * that can be used to add a WebSocket server to the HTTPS server. The WebSocket server sends messages + * about operation status changes to connected clients. + * + */ +export function tryEnableBuildStatusWebSocketServer( + options: IPhasedCommandHandlerOptions +): WebSocketServerUpgrader | undefined { + const { buildStatusWebSocketPath } = options; + if (!buildStatusWebSocketPath) { + return; + } + + const operationStates: Map = new Map(); + let buildStatus: ReadableOperationStatus = 'Ready'; + let executionAbortController: AbortController | undefined; + + const webSockets: Set = new Set(); + + // Map from OperationStatus enum values back to the names of the constants + const readableStatusFromStatus: { + [K in OperationStatus]: ReadableOperationStatus; + } = { + [OperationStatus.Waiting]: 'Waiting', + [OperationStatus.Ready]: 'Ready', + [OperationStatus.Queued]: 'Queued', + [OperationStatus.Executing]: 'Executing', + [OperationStatus.Success]: 'Success', + [OperationStatus.SuccessWithWarning]: 'SuccessWithWarning', + [OperationStatus.Skipped]: 'Skipped', + [OperationStatus.FromCache]: 'FromCache', + [OperationStatus.Failure]: 'Failure', + [OperationStatus.Blocked]: 'Blocked', + [OperationStatus.NoOp]: 'NoOp', + [OperationStatus.Aborted]: 'Aborted' + }; + + const { logServePath } = options; + + function convertToLogFileUrls( + logFilePaths: ILogFilePaths | undefined, + packageName: string + ): ILogFileURLs | undefined { + if (!logFilePaths || !logServePath) { + return; + } + + const projectLogServePath: string = getLogServePathForProject(logServePath, packageName); + + const logFileUrls: ILogFileURLs = { + text: `${projectLogServePath}${logFilePaths.text.slice(logFilePaths.textFolder.length)}`, + error: `${projectLogServePath}${logFilePaths.error.slice(logFilePaths.textFolder.length)}`, + jsonl: `${projectLogServePath}${logFilePaths.jsonl.slice(logFilePaths.jsonlFolder.length)}` + }; + + return logFileUrls; + } + + /** + * Maps the internal Rush record down to a subset that is JSON-friendly and human readable. + */ + function convertToOperationInfo(record: IOperationExecutionResult): IOperationInfo | undefined { + const { operation } = record; + const { name, associatedPhase, associatedProject, runner, enabled } = operation; + + if (!name || !runner) { + return; + } + + const { packageName } = associatedProject; + + return { + name, + dependencies: Array.from(operation.dependencies, (dep) => dep.name), + packageName, + phaseName: associatedPhase.name, + + enabled, + silent: runner.silent, + noop: !!runner.isNoOp, + + status: readableStatusFromStatus[record.status], + startTime: record.stopwatch.startTime, + endTime: record.stopwatch.endTime, + + logFileURLs: convertToLogFileUrls(record.logFilePaths, packageName) + }; + } + + function convertToOperationInfoArray(records: Iterable): IOperationInfo[] { + const operations: IOperationInfo[] = []; + + for (const record of records) { + const info: IOperationInfo | undefined = convertToOperationInfo(record); + + if (info) { + operations.push(info); + } + } + + Sort.sortBy(operations, (x) => x.name); + return operations; + } + + function sendWebSocketMessage(message: IWebSocketEventMessage): void { + const stringifiedMessage: string = JSON.stringify(message); + for (const socket of webSockets) { + socket.send(stringifiedMessage); + } + } + + const { command } = options; + const sessionInfo: IRushSessionInfo = { + actionName: command.actionName, + repositoryIdentifier: getRepositoryIdentifier(options.rushConfiguration) + }; + + function sendSyncMessage(webSocket: WebSocket): void { + const syncMessage: IWebSocketSyncEventMessage = { + event: 'sync', + operations: convertToOperationInfoArray(operationStates?.values() ?? []), + sessionInfo, + status: buildStatus + }; + + webSocket.send(JSON.stringify(syncMessage)); + } + + const { hooks } = command; + + let invalidateOperation: ((operation: Operation, reason: string) => void) | undefined; + + const operationEnabledStates: Map = new Map(); + hooks.createOperations.tap( + { + name: PLUGIN_NAME, + stage: Infinity + }, + (operations: Set, context: ICreateOperationsContext) => { + const potentiallyAffectedOperations: Set = new Set(); + for (const operation of operations) { + const { associatedProject } = operation; + if (context.projectsInUnknownState.has(associatedProject)) { + potentiallyAffectedOperations.add(operation); + } + } + for (const operation of potentiallyAffectedOperations) { + for (const consumer of operation.consumers) { + potentiallyAffectedOperations.add(consumer); + } + + const { name } = operation; + const expectedState: OperationEnabledState | undefined = operationEnabledStates.get(name); + switch (expectedState) { + case 'affected': + operation.enabled = true; + break; + case 'never': + operation.enabled = false; + break; + case 'changed': + operation.enabled = context.projectsInUnknownState.has(operation.associatedProject); + break; + case 'default': + case undefined: + // Use the original value. + break; + } + } + + invalidateOperation = context.invalidateOperation; + + return operations; + } + ); + + hooks.beforeExecuteOperations.tap( + PLUGIN_NAME, + ( + operationsToExecute: Map, + context: IExecuteOperationsContext + ): void => { + for (const [operation, result] of operationsToExecute) { + operationStates.set(operation.name, result); + } + + executionAbortController = context.abortController; + + const beforeExecuteMessage: IWebSocketBeforeExecuteEventMessage = { + event: 'before-execute', + operations: convertToOperationInfoArray(operationsToExecute.values()) + }; + buildStatus = 'Executing'; + sendWebSocketMessage(beforeExecuteMessage); + } + ); + + hooks.afterExecuteOperations.tap(PLUGIN_NAME, (result: IExecutionResult): void => { + buildStatus = readableStatusFromStatus[result.status]; + const infos: IOperationInfo[] = convertToOperationInfoArray(result.operationResults.values() ?? []); + const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { + event: 'after-execute', + operations: infos, + status: buildStatus + }; + sendWebSocketMessage(afterExecuteMessage); + }); + + const pendingStatusChanges: Map = new Map(); + let statusChangeTimeout: NodeJS.Immediate | undefined; + function sendBatchedStatusChange(): void { + statusChangeTimeout = undefined; + const infos: IOperationInfo[] = convertToOperationInfoArray(pendingStatusChanges.values()); + pendingStatusChanges.clear(); + const message: IWebSocketBatchStatusChangeEventMessage = { + event: 'status-change', + operations: infos + }; + sendWebSocketMessage(message); + } + + hooks.onOperationStatusChanged.tap(PLUGIN_NAME, (record: IOperationExecutionResult): void => { + pendingStatusChanges.set(record.operation, record); + if (!statusChangeTimeout) { + statusChangeTimeout = setImmediate(sendBatchedStatusChange); + } + }); + + const connector: WebSocketServerUpgrader = (server: Http2SecureServer) => { + const wss: WebSocketServer = new WebSocketServer({ + server: server as unknown as HTTPSecureServer, + path: buildStatusWebSocketPath + }); + wss.addListener('connection', (webSocket: WebSocket): void => { + webSockets.add(webSocket); + + sendSyncMessage(webSocket); + + webSocket.addEventListener('message', (ev: MessageEvent) => { + const parsedMessage: IWebSocketCommandMessage = JSON.parse(ev.data.toString()); + switch (parsedMessage.command) { + case 'sync': { + sendSyncMessage(webSocket); + break; + } + + case 'set-enabled-states': { + const { enabledStateByOperationName } = parsedMessage; + for (const [name, state] of Object.entries(enabledStateByOperationName)) { + operationEnabledStates.set(name, state); + } + break; + } + + case 'invalidate': { + const { operationNames } = parsedMessage; + const operationNameSet: Set = new Set(operationNames); + if (invalidateOperation) { + for (const operationName of operationNameSet) { + const operationState: IOperationExecutionResult | undefined = + operationStates.get(operationName); + if (operationState) { + invalidateOperation(operationState.operation, 'Invalidated via WebSocket'); + operationStates.delete(operationName); + } + } + } + break; + } + + case 'abort-execution': { + executionAbortController?.abort(); + break; + } + + default: { + // Unknown message. Ignore. + } + } + }); + + webSocket.addEventListener( + 'close', + () => { + webSockets.delete(webSocket); + }, + { once: true } + ); + }); + }; + + return connector; +} diff --git a/rush-plugins/rush-serve-plugin/src/types.ts b/rush-plugins/rush-serve-plugin/src/types.ts new file mode 100644 index 00000000000..8e5473c8d94 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/types.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushConfiguration, RushSession, IPhasedCommand } from '@rushstack/rush-sdk'; + +export interface IPhasedCommandHandlerOptions { + rushSession: RushSession; + rushConfiguration: RushConfiguration; + command: IPhasedCommand; + portParameterLongName: string | undefined; + logServePath: string | undefined; + globalRoutingRules: IRoutingRule[]; + buildStatusWebSocketPath: string | undefined; +} +export interface IRushProjectServeJson { + routing: IRoutingRuleJson[]; +} + +export interface IBaseRoutingRuleJson { + servePath: string; + immutable?: boolean; +} + +export interface IRoutingFolderRuleJson extends IBaseRoutingRuleJson { + projectRelativeFile: undefined; + projectRelativeFolder: string; +} + +export interface IRoutingFileRuleJson extends IBaseRoutingRuleJson { + projectRelativeFile: string; + projectRelativeFolder: undefined; +} + +export type IRoutingRuleJson = IRoutingFileRuleJson | IRoutingFolderRuleJson; + +export interface IRoutingRule { + type: 'file' | 'folder'; + diskPath: string; + servePath: string; + immutable: boolean; +} From d1bd1f2fc01146d01b5c4a40d2e89f2828d2d479 Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Thu, 18 Sep 2025 15:00:33 -0400 Subject: [PATCH 18/22] reviews --- .../rush/eb-concurrency-bug-fix_2025-09-16-19-06.json | 4 ++-- .../eb-concurrency-bug-fix_2025-09-11-15-24.json | 2 +- libraries/node-core-library/src/Async.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json index 48dae0db27c..9f3742023b6 100644 --- a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json +++ b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json @@ -2,8 +2,8 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "add allowOversubscription option to prevent running tasks from exceeding concurrency", - "type": "minor" + "comment": "Add an `allowOversubscription` option to the command definitions in `common/config/rush/command-line.json` to prevent running tasks from exceeding concurrency.", + "type": "" } ], "packageName": "@microsoft/rush" diff --git a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json index 9b543642f9e..2d0115b64f6 100644 --- a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json +++ b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Add allowOversubscription option to prevent running tasks from exceeding concurrency", + "comment": "Add an `allowOversubscription` option to the `Async` API functions which prevents running tasks from exceeding concurrency.", "type": "minor" } ], diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 5034d62e0d8..098c5d3eaa6 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -222,7 +222,7 @@ export class Async { // there will be effectively no cap on the number of operations waiting. const limitedConcurrency: number = !Number.isFinite(concurrency) ? 1 : concurrency; concurrentUnitsInProgress += limitedConcurrency; - const currentIteratorResult: IteratorResult = nextIterator || (await iterator.next()); + const currentIteratorResult: IteratorResult = nextIterator ?? (await iterator.next()); // eslint-disable-next-line require-atomic-updates iteratorIsComplete = !!currentIteratorResult.done; From 9bdaab2a9bf0119bbb57ea07fb2a724b46e90255 Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Thu, 18 Sep 2025 15:04:03 -0400 Subject: [PATCH 19/22] pull bump type from version-policies --- .../rush/eb-concurrency-bug-fix_2025-09-16-19-06.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json index 9f3742023b6..fecd9134afc 100644 --- a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json +++ b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json @@ -3,7 +3,7 @@ { "packageName": "@microsoft/rush", "comment": "Add an `allowOversubscription` option to the command definitions in `common/config/rush/command-line.json` to prevent running tasks from exceeding concurrency.", - "type": "" + "type": "patch" } ], "packageName": "@microsoft/rush" From 44f794f7b7d38c6082ed87e67a180ebd397a3dac Mon Sep 17 00:00:00 2001 From: ethanburrelldd <223327898+ethanburrelldd@users.noreply.github.com.> Date: Fri, 19 Sep 2025 10:40:03 -0400 Subject: [PATCH 20/22] change to none --- .../rush/eb-concurrency-bug-fix_2025-09-16-19-06.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json index fecd9134afc..12fe1d7e9c5 100644 --- a/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json +++ b/common/changes/@microsoft/rush/eb-concurrency-bug-fix_2025-09-16-19-06.json @@ -3,7 +3,7 @@ { "packageName": "@microsoft/rush", "comment": "Add an `allowOversubscription` option to the command definitions in `common/config/rush/command-line.json` to prevent running tasks from exceeding concurrency.", - "type": "patch" + "type": "none" } ], "packageName": "@microsoft/rush" From c472ad5f974050c47ff2ccb79e7491137bf1bde7 Mon Sep 17 00:00:00 2001 From: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> Date: Sun, 21 Sep 2025 19:41:59 -0400 Subject: [PATCH 21/22] [lockfile-explorer] Isolate .pnpmcfile.cjs execution and add syntax highlighter (#5366) * Enable syntax highlighting for pnpmfile.cjs tab * Highlight package.json as well * Introduce PnpmfileRunner.ts to isolate .pnpmfile.cjs execution * rush change * Disable webpack bundle size limits, since this app is served from localhost * In a Rush repo, the ".pnpmfile.cjs" tab should show Rush's file, not the generated temp file * Upgrade Prettier to support `async using` * prettier -w . * PR feedback: use "await using" * PR feedback * PR feedback * rush change * PR feedback * Fix build break * Revert `[Symbol.asyncDispose]()` because it isn't supported by Node 18 --- apps/lockfile-explorer-web/package.json | 1 + .../containers/PackageJsonViewer/CodeBox.tsx | 79 ++ .../containers/PackageJsonViewer/index.tsx | 13 +- .../packlets/lfx-shared/IJsonLfxWorkspace.ts | 17 + apps/lockfile-explorer-web/webpack.config.js | 6 +- .../cli/explorer/ExplorerCommandLineParser.ts | 37 +- .../src/graph/IPnpmfileModule.ts | 24 + .../src/graph/PnpmfileRunner.ts | 107 ++ .../src/graph/pnpmfileRunnerWorkerThread.ts | 96 ++ .../src/graph/test/PnpmfileRunner.test.ts | 67 + .../lfxGraph-edge-cases-v5.4.test.ts.snap | 1 + .../lfxGraph-edge-cases-v6.0.test.ts.snap | 1 + ...fxGraph-website-sample-1-v5.4.test.ts.snap | 2 + ...fxGraph-website-sample-1-v6.0.test.ts.snap | 2 + .../fixtures/PnpmfileRunner/.pnpmfile.cjs | 24 + .../test/lfxGraph-edge-cases-v5.4.test.ts | 1 + .../test/lfxGraph-edge-cases-v6.0.test.ts | 1 + .../lfxGraph-website-sample-1-v5.4.test.ts | 4 +- .../lfxGraph-website-sample-1-v6.0.test.ts | 4 +- .../src/graph/test/serializeToJson.test.ts | 2 + .../src/graph/test/testLockfile.ts | 4 +- apps/lockfile-explorer/src/utils/init.ts | 24 +- .../common/scripts/install-run-rush-pnpm.js | 23 +- .../common/scripts/install-run-rush.js | 361 ++--- .../common/scripts/install-run-rushx.js | 23 +- .../common/scripts/install-run.js | 1256 +++++++++-------- .../autoinstallers/rush-prettier/package.json | 4 +- .../rush-prettier/pnpm-lock.yaml | 188 +-- .../octogonz-lfx-fixes4_2025-09-21-19-46.json | 10 + .../octogonz-lfx-fixes4_2025-09-21-19-46.json | 10 + .../octogonz-lfx-fixes4_2025-09-17-17-57.json | 10 + .../octogonz-lfx-fixes4_2025-09-17-17-58.json | 10 + .../rush/browser-approved-packages.json | 4 + .../config/subspaces/default/pnpm-lock.yaml | 22 + .../config/subspaces/default/repo-state.json | 2 +- .../src/SwcIsolatedTranspilePlugin.ts | 4 +- libraries/rush-lib/src/api/VersionPolicy.ts | 4 +- .../pnpm-lock-v9/inconsistent-dep-devDep.yaml | 10 +- .../test/yamlFiles/pnpm-lock-v9/modified.yaml | 15 +- .../yamlFiles/pnpm-lock-v9/not-modified.yaml | 15 +- .../pnpm-lock-v9/overrides-not-modified.yaml | 15 +- .../yamlFiles/pnpm-lock-v9/pnpm-lock-v9.yaml | 22 +- 42 files changed, 1525 insertions(+), 1000 deletions(-) create mode 100644 apps/lockfile-explorer-web/src/containers/PackageJsonViewer/CodeBox.tsx create mode 100644 apps/lockfile-explorer/src/graph/IPnpmfileModule.ts create mode 100644 apps/lockfile-explorer/src/graph/PnpmfileRunner.ts create mode 100644 apps/lockfile-explorer/src/graph/pnpmfileRunnerWorkerThread.ts create mode 100644 apps/lockfile-explorer/src/graph/test/PnpmfileRunner.test.ts create mode 100644 apps/lockfile-explorer/src/graph/test/fixtures/PnpmfileRunner/.pnpmfile.cjs create mode 100644 common/changes/@microsoft/rush/octogonz-lfx-fixes4_2025-09-21-19-46.json create mode 100644 common/changes/@rushstack/heft-isolated-typescript-transpile-plugin/octogonz-lfx-fixes4_2025-09-21-19-46.json create mode 100644 common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-57.json create mode 100644 common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-58.json diff --git a/apps/lockfile-explorer-web/package.json b/apps/lockfile-explorer-web/package.json index 59923434733..bc10ae7500c 100644 --- a/apps/lockfile-explorer-web/package.json +++ b/apps/lockfile-explorer-web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@reduxjs/toolkit": "~1.8.6", "@rushstack/rush-themed-ui": "workspace:*", + "prism-react-renderer": "~2.4.1", "react-dom": "~17.0.2", "react-redux": "~8.0.4", "react": "~17.0.2", diff --git a/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/CodeBox.tsx b/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/CodeBox.tsx new file mode 100644 index 00000000000..6294d35a121 --- /dev/null +++ b/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/CodeBox.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import React from 'react'; + +import { Highlight, themes } from 'prism-react-renderer'; + +// Generate this list by doing console.log(Object.keys(Prism.languages)) +// BUT THEN DELETE the APIs that are bizarrely mixed into this namespace: +// "extend", "insertBefore", "DFS" +export type PrismLanguage = + | 'plain' + | 'plaintext' + | 'text' + | 'txt' + | 'markup' + | 'html' + | 'mathml' + | 'svg' + | 'xml' + | 'ssml' + | 'atom' + | 'rss' + | 'regex' + | 'clike' + | 'javascript' + | 'js' + | 'actionscript' + | 'coffeescript' + | 'coffee' + | 'javadoclike' + | 'css' + | 'yaml' + | 'yml' + | 'markdown' + | 'md' + | 'graphql' + | 'sql' + | 'typescript' + | 'ts' + | 'jsdoc' + | 'flow' + | 'n4js' + | 'n4jsd' + | 'jsx' + | 'tsx' + | 'swift' + | 'kotlin' + | 'kt' + | 'kts' + | 'c' + | 'objectivec' + | 'objc' + | 'reason' + | 'rust' + | 'go' + | 'cpp' + | 'python' + | 'py' + | 'json' + | 'webmanifest'; + +export const CodeBox = (props: { code: string; language: PrismLanguage }): JSX.Element => { + return ( + + {({ className, style, tokens, getLineProps, getTokenProps }) => ( +
+          {tokens.map((line, i) => (
+            
+ {line.map((token, key) => ( + + ))} +
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/index.tsx b/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/index.tsx index b7588f360a7..a0be0ebd7c3 100644 --- a/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/index.tsx +++ b/apps/lockfile-explorer-web/src/containers/PackageJsonViewer/index.tsx @@ -2,8 +2,8 @@ // See LICENSE in the project root for license information. import React, { useCallback, useEffect, useState } from 'react'; + import { readPnpmfileAsync, readPackageSpecAsync, readPackageJsonAsync } from '../../helpers/lfxApiClient'; -import styles from './styles.scss'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { selectCurrentEntry } from '../../store/slices/entrySlice'; import type { IPackageJson } from '../../types/IPackageJson'; @@ -13,6 +13,9 @@ import { displaySpecChanges } from '../../helpers/displaySpecChanges'; import { isEntryModified } from '../../helpers/isEntryModified'; import { ScrollArea, Tabs, Text } from '@rushstack/rush-themed-ui'; import { LfxGraphEntryKind } from '../../packlets/lfx-shared'; +import { CodeBox } from './CodeBox'; + +import styles from './styles.scss'; const PackageView: { [key: string]: string } = { PACKAGE_JSON: 'PACKAGE_JSON', @@ -48,9 +51,9 @@ export const PackageJsonViewer = (): JSX.Element => { useEffect(() => { async function loadPackageDetailsAsync(packageName: string): Promise { - const packageJSONFile = await readPackageJsonAsync(packageName); + const packageJSONFile: IPackageJson | undefined = await readPackageJsonAsync(packageName); setPackageJSON(packageJSONFile); - const parsedJSON = await readPackageSpecAsync(packageName); + const parsedJSON: IPackageJson | undefined = await readPackageSpecAsync(packageName); setParsedPackageJSON(parsedJSON); if (packageJSONFile && parsedJSON) { @@ -161,7 +164,7 @@ export const PackageJsonViewer = (): JSX.Element => { Please select a Project or Package to view it's package.json ); - return
{JSON.stringify(packageJSON, null, 2)}
; + return ; case PackageView.PACKAGE_SPEC: if (!pnpmfile) { return ( @@ -171,7 +174,7 @@ export const PackageJsonViewer = (): JSX.Element => { ); } - return
{pnpmfile}
; + return ; case PackageView.PARSED_PACKAGE_JSON: if (!parsedPackageJSON) return ( diff --git a/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts b/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts index 841f95e763d..d5115c1b33b 100644 --- a/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts +++ b/apps/lockfile-explorer-web/src/packlets/lfx-shared/IJsonLfxWorkspace.ts @@ -12,6 +12,15 @@ export interface IJsonLfxWorkspaceRushConfig { * Otherwise this will be an empty string. */ readonly subspaceName: string; + + /** + * The path to Rush's input file `.pnpmfile.cjs`, relative to `workspaceRootFullPath` + * and normalized to use forward slashes without a leading slash. In a Rush workspace, + * {@link IJsonLfxWorkspace.pnpmfilePath} is a temporary file that is generated from `rushPnpmfilePath`. + * + * @example `"common/config/my-subspace/pnpm-lock.yaml"` + */ + readonly rushPnpmfilePath: string; } export interface IJsonLfxWorkspace { @@ -44,6 +53,14 @@ export interface IJsonLfxWorkspace { */ readonly pnpmLockfileFolder: string; + /** + * The path to the `.pnpmfile.cjs` file that is loaded by PNPM. In a Rush workspace, + * this is a temporary file that is generated from `rushPnpmfilePath`. + * + * @example `"common/temp/my-subspace/.pnpmfile.cjs"` + */ + readonly pnpmfilePath: string; + /** * This section will be defined only if this is a Rush workspace (versus a plain PNPM workspace). */ diff --git a/apps/lockfile-explorer-web/webpack.config.js b/apps/lockfile-explorer-web/webpack.config.js index 10ce2633322..7b09bf573cd 100644 --- a/apps/lockfile-explorer-web/webpack.config.js +++ b/apps/lockfile-explorer-web/webpack.config.js @@ -17,11 +17,11 @@ module.exports = function createConfig(env, argv) { } }, performance: { - hints: env.production ? 'error' : false + hints: env.production ? 'error' : false, // This specifies the bundle size limit that will trigger Webpack's warning saying: // "The following entrypoint(s) combined asset size exceeds the recommended limit." - // maxEntrypointSize: 500000, - // maxAssetSize: 500000 + maxEntrypointSize: Infinity, + maxAssetSize: Infinity }, devServer: { port: 8096, diff --git a/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts b/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts index cf0bd6e7839..aa351cd1e81 100644 --- a/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts +++ b/apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts @@ -7,7 +7,7 @@ import cors from 'cors'; import process from 'process'; import open from 'open'; import updateNotifier from 'update-notifier'; - +import * as path from 'node:path'; import { FileSystem, type IPackageJson, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library'; import { ConsoleTerminalProvider, type ITerminal, Terminal, Colorize } from '@rushstack/terminal'; import { @@ -15,15 +15,19 @@ import { CommandLineParser, type IRequiredCommandLineStringParameter } from '@rushstack/ts-command-line'; + import { type LfxGraph, lfxGraphSerializer, type IAppContext, - type IJsonLfxGraph + type IJsonLfxGraph, + type IJsonLfxWorkspace } from '../../../build/lfx-shared'; +import * as lockfilePath from '../../graph/lockfilePath'; import type { IAppState } from '../../state'; import { init } from '../../utils/init'; +import { PnpmfileRunner } from '../../graph/PnpmfileRunner'; import * as lfxGraphLoader from '../../graph/lfxGraphLoader'; const EXPLORER_TOOL_FILENAME: 'lockfile-explorer' = 'lockfile-explorer'; @@ -98,6 +102,8 @@ export class ExplorerCommandLineParser extends CommandLineParser { subspaceName: this._subspaceParameter.value }); + const lfxWorkspace: IJsonLfxWorkspace = appState.lfxWorkspace; + // Important: This must happen after init() reads the current working directory process.chdir(appState.lockfileExplorerProjectRoot); @@ -153,7 +159,7 @@ export class ExplorerCommandLineParser extends CommandLineParser { const pnpmLockfileText: string = await FileSystem.readFileAsync(appState.pnpmLockfileLocation); const lockfile: unknown = yaml.load(pnpmLockfileText) as unknown; - const graph: LfxGraph = lfxGraphLoader.generateLockfileGraph(lockfile, appState.lfxWorkspace); + const graph: LfxGraph = lfxGraphLoader.generateLockfileGraph(lockfile, lfxWorkspace); const jsonGraph: IJsonLfxGraph = lfxGraphSerializer.serializeToJson(graph); res.type('application/json').send(jsonGraph); @@ -183,13 +189,18 @@ export class ExplorerCommandLineParser extends CommandLineParser { ); app.get('/api/pnpmfile', async (req: express.Request, res: express.Response) => { + const pnpmfilePath: string = lockfilePath.join( + lfxWorkspace.workspaceRootFullPath, + lfxWorkspace.rushConfig?.rushPnpmfilePath ?? lfxWorkspace.pnpmfilePath + ); + let pnpmfile: string; try { - pnpmfile = await FileSystem.readFileAsync(appState.pnpmfileLocation); + pnpmfile = await FileSystem.readFileAsync(pnpmfilePath); } catch (e) { if (FileSystem.isNotExistError(e)) { return res.status(404).send({ - message: `Could not load pnpmfile file in this repo.`, + message: `Could not load .pnpmfile.cjs file in this repo: "${pnpmfilePath}"`, error: `No .pnpmifile.cjs found.` }); } else { @@ -218,10 +229,18 @@ export class ExplorerCommandLineParser extends CommandLineParser { } } - const { - hooks: { readPackage } - } = require(appState.pnpmfileLocation); - const parsedPackage: {} = readPackage(packageJson, {}); + let parsedPackage: IPackageJson = packageJson; + + const pnpmfilePath: string = path.join(lfxWorkspace.workspaceRootFullPath, lfxWorkspace.pnpmfilePath); + if (await FileSystem.existsAsync(pnpmfilePath)) { + const pnpmFileRunner: PnpmfileRunner = new PnpmfileRunner(pnpmfilePath); + try { + parsedPackage = await pnpmFileRunner.transformPackageAsync(packageJson, fileLocation); + } finally { + await pnpmFileRunner.disposeAsync(); + } + } + res.send(parsedPackage); } ); diff --git a/apps/lockfile-explorer/src/graph/IPnpmfileModule.ts b/apps/lockfile-explorer/src/graph/IPnpmfileModule.ts new file mode 100644 index 00000000000..05ee1eae2e7 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/IPnpmfileModule.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IPackageJson } from '@rushstack/node-core-library'; + +export interface IReadPackageContext { + log: (message: string) => void; +} + +export type IReadPackageHook = ( + packageJson: IPackageJson, + context: IReadPackageContext +) => IPackageJson | Promise; + +export interface IPnpmHooks { + readPackage?: IReadPackageHook; +} + +/** + * Type of the `.pnpmfile.cjs` module. + */ +export interface IPnpmfileModule { + hooks?: IPnpmHooks; +} diff --git a/apps/lockfile-explorer/src/graph/PnpmfileRunner.ts b/apps/lockfile-explorer/src/graph/PnpmfileRunner.ts new file mode 100644 index 00000000000..6049f215fcb --- /dev/null +++ b/apps/lockfile-explorer/src/graph/PnpmfileRunner.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Worker } from 'node:worker_threads'; +import * as path from 'node:path'; +import type { IPackageJson } from '@rushstack/node-core-library'; + +import type { IRequestMessage, ResponseMessage } from './pnpmfileRunnerWorkerThread'; + +interface IPromise { + resolve: (r: IPackageJson) => void; + reject: (e: Error) => void; +} + +/** + * Evals `.pnpmfile.cjs` in an isolated thread, so `transformPackageAsync()` can be used to rewrite + * package.json files. Calling `disposeAsync()` will free the loaded modules. + */ +export class PnpmfileRunner { + private _worker: Worker; + private _nextId: number = 1000; + private _promisesById: Map = new Map(); + private _disposed: boolean = false; + + public logger: ((message: string) => void) | undefined = undefined; + + public constructor(pnpmfilePath: string) { + this._worker = new Worker(path.join(`${__dirname}/pnpmfileRunnerWorkerThread.js`), { + workerData: { pnpmfilePath } + }); + + this._worker.on('message', (message: ResponseMessage) => { + const id: number = message.id; + const promise: IPromise | undefined = this._promisesById.get(id); + if (!promise) { + return; + } + + if (message.kind === 'return') { + this._promisesById.delete(id); + // TODO: Validate the user's readPackage() return value + const result: IPackageJson = message.result as IPackageJson; + promise.resolve(result); + } else if (message.kind === 'log') { + // No this._promisesById.delete(id) for this case + if (this.logger) { + this.logger(message.log); + } else { + console.log('.pnpmfile.cjs: ' + message.log); + } + } else { + this._promisesById.delete(id); + promise.reject(new Error(message.error || 'An unknown error occurred')); + } + }); + + this._worker.on('error', (err) => { + for (const promise of this._promisesById.values()) { + promise.reject(err); + } + this._promisesById.clear(); + }); + + this._worker.on('exit', (code) => { + if (!this._disposed) { + const error: Error = new Error( + `PnpmfileRunner worker thread terminated unexpectedly with exit code ${code}` + ); + console.error(error); + for (const promise of this._promisesById.values()) { + promise.reject(error); + } + this._promisesById.clear(); + } + }); + } + + /** + * Invokes the readPackage() hook from .pnpmfile.cjs + */ + public transformPackageAsync( + packageJson: IPackageJson, + packageJsonFullPath: string + ): Promise { + if (this._disposed) { + return Promise.reject(new Error('The operation failed because PnpmfileRunner has been disposed')); + } + + const id: number = this._nextId++; + return new Promise((resolve, reject) => { + this._promisesById.set(id, { resolve, reject }); + this._worker.postMessage({ id, packageJson, packageJsonFullPath } satisfies IRequestMessage); + }); + } + + public async disposeAsync(): Promise { + if (this._disposed) { + return; + } + for (const pending of this._promisesById.values()) { + pending.reject(new Error('Aborted because PnpmfileRunner was disposed')); + } + this._promisesById.clear(); + this._disposed = true; + await this._worker.terminate(); + } +} diff --git a/apps/lockfile-explorer/src/graph/pnpmfileRunnerWorkerThread.ts b/apps/lockfile-explorer/src/graph/pnpmfileRunnerWorkerThread.ts new file mode 100644 index 00000000000..da87f43b115 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/pnpmfileRunnerWorkerThread.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parentPort, workerData, type MessagePort } from 'node:worker_threads'; +import * as path from 'node:path'; +import type { IPackageJson } from '@rushstack/node-core-library'; + +import type { IPnpmfileModule, IReadPackageContext } from './IPnpmfileModule'; + +export interface IRequestMessage { + id: number; + packageJson: IPackageJson; + packageJsonFullPath: string; +} + +export interface IResponseMessageLog { + kind: 'log'; + id: number; + log: string; +} +export interface IResponseMessageError { + kind: 'error'; + id: number; + error: string; +} +export interface IResponseMessageReturn { + kind: 'return'; + id: number; + result?: unknown; +} +export type ResponseMessage = IResponseMessageLog | IResponseMessageError | IResponseMessageReturn; + +// debugger; + +const { pnpmfilePath } = workerData; +const resolvedPath: string = path.resolve(pnpmfilePath); + +let pnpmfileModule: IPnpmfileModule | undefined = undefined; +let pnpmfileModuleError: Error | undefined = undefined; + +try { + pnpmfileModule = require(resolvedPath); +} catch (error) { + pnpmfileModuleError = error; +} + +// eslint-disable-next-line @rushstack/no-new-null +const threadParentPort: null | MessagePort = parentPort; + +if (!threadParentPort) { + throw new Error('Not running in a worker thread'); +} + +threadParentPort.on('message', async (message: IRequestMessage) => { + const { id, packageJson } = message; + + if (pnpmfileModuleError) { + threadParentPort.postMessage({ + kind: 'error', + id, + error: pnpmfileModuleError.message + } satisfies IResponseMessageError); + return; + } + + try { + if (!pnpmfileModule || !pnpmfileModule.hooks || typeof pnpmfileModule.hooks.readPackage !== 'function') { + // No transformation needed + threadParentPort.postMessage({ + kind: 'return', + id, + result: packageJson + } satisfies IResponseMessageReturn); + return; + } + + const pnpmContext: IReadPackageContext = { + log: (logMessage) => + threadParentPort.postMessage({ + kind: 'log', + id, + log: logMessage + } satisfies IResponseMessageLog) + }; + + const result: IPackageJson = await pnpmfileModule.hooks.readPackage({ ...packageJson }, pnpmContext); + + threadParentPort.postMessage({ kind: 'return', id, result } satisfies IResponseMessageReturn); + } catch (e) { + threadParentPort.postMessage({ + kind: 'error', + id, + error: (e as Error).message + } satisfies IResponseMessageError); + } +}); diff --git a/apps/lockfile-explorer/src/graph/test/PnpmfileRunner.test.ts b/apps/lockfile-explorer/src/graph/test/PnpmfileRunner.test.ts new file mode 100644 index 00000000000..118e75afbba --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/PnpmfileRunner.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import inspector from 'inspector'; +import { Path } from '@rushstack/node-core-library'; +import { PnpmfileRunner } from '../PnpmfileRunner'; + +const isDebuggerAttached: boolean = inspector.url() !== undefined; + +// Since we're spawning another thread, increase the timeout to 10s. +// For debugging, use an infinite timeout. +jest.setTimeout(isDebuggerAttached ? 1e9 : 10000); + +describe(PnpmfileRunner.name, () => { + it('transforms a package.json file', async () => { + const dirname: string = Path.convertToSlashes(__dirname); + const libIndex: number = dirname.lastIndexOf('/lib/'); + if (libIndex < 0) { + throw new Error('Unexpected file path'); + } + const srcDirname: string = + dirname.substring(0, libIndex) + '/src/' + dirname.substring(libIndex + '/lib/'.length); + + const pnpmfilePath: string = srcDirname + '/fixtures/PnpmfileRunner/.pnpmfile.cjs'; + const logMessages: string[] = []; + + const pnpmfileRunner: PnpmfileRunner = new PnpmfileRunner(pnpmfilePath); + try { + pnpmfileRunner.logger = (message) => { + logMessages.push(message); + }; + expect( + await pnpmfileRunner.transformPackageAsync( + { + name: '@types/karma', + version: '1.0.0', + dependencies: { + 'example-dependency': '1.0.0' + } + }, + pnpmfilePath + ) + ).toMatchInlineSnapshot(` +Object { + "dependencies": Object { + "example-dependency": "1.0.0", + "log4js": "0.6.38", + }, + "name": "@types/karma", + "version": "1.0.0", +} +`); + } finally { + await pnpmfileRunner.disposeAsync(); + } + + expect(logMessages).toMatchInlineSnapshot(` +Array [ + "Fixed up dependencies for @types/karma", +] +`); + + await expect( + pnpmfileRunner.transformPackageAsync({ name: 'name', version: '1.0.0' }, '') + ).rejects.toThrow('disposed'); + }); +}); diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap index 382a4991800..ff8058f4180 100644 --- a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v5.4.test.ts.snap @@ -160,6 +160,7 @@ exports[`lfxGraph-edge-cases-v5.4 loads a workspace 1`] = ` workspace: pnpmLockfileFolder: '' pnpmLockfilePath: pnpm-lock.yaml + pnpmfilePath: .pnpmfile.cjs workspaceRootFullPath: /repo " `; diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap index 382a4991800..ff8058f4180 100644 --- a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-edge-cases-v6.0.test.ts.snap @@ -160,6 +160,7 @@ exports[`lfxGraph-edge-cases-v5.4 loads a workspace 1`] = ` workspace: pnpmLockfileFolder: '' pnpmLockfilePath: pnpm-lock.yaml + pnpmfilePath: .pnpmfile.cjs workspaceRootFullPath: /repo " `; diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap index a79208cc8c9..58b8bf4cb78 100644 --- a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v5.4.test.ts.snap @@ -317,7 +317,9 @@ exports[`lfxGraph-website-sample-1-v5.4 loads a workspace 1`] = ` workspace: pnpmLockfileFolder: common/temp pnpmLockfilePath: common/temp/pnpm-lock.yaml + pnpmfilePath: common/temp/.pnpmfile.cjs rushConfig: + rushPnpmfilePath: common/config/.pnpmfile.cjs rushVersion: 5.83.3 subspaceName: '' workspaceRootFullPath: /repo diff --git a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap index 7f65a88ff52..64f461236c5 100644 --- a/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap +++ b/apps/lockfile-explorer/src/graph/test/__snapshots__/lfxGraph-website-sample-1-v6.0.test.ts.snap @@ -263,7 +263,9 @@ exports[`lfxGraph-website-sample-1-v6.0 loads a workspace 1`] = ` workspace: pnpmLockfileFolder: common/temp pnpmLockfilePath: common/temp/pnpm-lock.yaml + pnpmfilePath: common/temp/.pnpmfile.cjs rushConfig: + rushPnpmfilePath: common/config/.pnpmcfile.cjs rushVersion: 5.158.1 subspaceName: '' workspaceRootFullPath: /repo diff --git a/apps/lockfile-explorer/src/graph/test/fixtures/PnpmfileRunner/.pnpmfile.cjs b/apps/lockfile-explorer/src/graph/test/fixtures/PnpmfileRunner/.pnpmfile.cjs new file mode 100644 index 00000000000..d7998d79e50 --- /dev/null +++ b/apps/lockfile-explorer/src/graph/test/fixtures/PnpmfileRunner/.pnpmfile.cjs @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + hooks: { + readPackage + } +}; + +/** + * This hook is invoked during installation before a package's dependencies + * are selected. + * The `packageJson` parameter is the deserialized package.json + * contents for the package that is about to be installed. + * The `context` parameter provides a log() function. + * The return value is the updated object. + */ +function readPackage(packageJson, context) { + if (packageJson.name === '@types/karma') { + context.log('Fixed up dependencies for @types/karma'); + packageJson.dependencies['log4js'] = '0.6.38'; + } + + return packageJson; +} diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts index 1a362d47c73..2aa4a13c230 100644 --- a/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v5.4.test.ts @@ -9,6 +9,7 @@ export const workspace: IJsonLfxWorkspace = { workspaceRootFullPath: '/repo', pnpmLockfilePath: 'pnpm-lock.yaml', pnpmLockfileFolder: '', + pnpmfilePath: '.pnpmfile.cjs', rushConfig: undefined }; diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts index 1a362d47c73..2aa4a13c230 100644 --- a/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-edge-cases-v6.0.test.ts @@ -9,6 +9,7 @@ export const workspace: IJsonLfxWorkspace = { workspaceRootFullPath: '/repo', pnpmLockfilePath: 'pnpm-lock.yaml', pnpmLockfileFolder: '', + pnpmfilePath: '.pnpmfile.cjs', rushConfig: undefined }; diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts index af8dc57b59d..7c74129e631 100644 --- a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v5.4.test.ts @@ -9,9 +9,11 @@ export const workspace: IJsonLfxWorkspace = { workspaceRootFullPath: '/repo', pnpmLockfilePath: 'common/temp/pnpm-lock.yaml', pnpmLockfileFolder: 'common/temp', + pnpmfilePath: 'common/temp/.pnpmfile.cjs', rushConfig: { rushVersion: '5.83.3', - subspaceName: '' + subspaceName: '', + rushPnpmfilePath: 'common/config/.pnpmfile.cjs' } }; diff --git a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts index 90231d947c4..0cf7e0c2c25 100644 --- a/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts +++ b/apps/lockfile-explorer/src/graph/test/lfxGraph-website-sample-1-v6.0.test.ts @@ -9,9 +9,11 @@ export const workspace: IJsonLfxWorkspace = { workspaceRootFullPath: '/repo', pnpmLockfilePath: 'common/temp/pnpm-lock.yaml', pnpmLockfileFolder: 'common/temp', + pnpmfilePath: 'common/temp/.pnpmfile.cjs', rushConfig: { rushVersion: '5.158.1', - subspaceName: '' + subspaceName: '', + rushPnpmfilePath: 'common/config/.pnpmcfile.cjs' } }; diff --git a/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts b/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts index 46ffc2c09db..d05769f2e81 100644 --- a/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts +++ b/apps/lockfile-explorer/src/graph/test/serializeToJson.test.ts @@ -88,7 +88,9 @@ Object { "workspace": Object { "pnpmLockfileFolder": "common/temp/my-subspace", "pnpmLockfilePath": "common/temp/my-subspace/pnpm-lock.yaml", + "pnpmfilePath": "common/temp/my-subspace/.pnpmfile.cjs", "rushConfig": Object { + "rushPnpmfilePath": "common/config/subspaces/my-subspace/.pnpmfile.cjs", "rushVersion": "0.0.0", "subspaceName": "my-subspace", }, diff --git a/apps/lockfile-explorer/src/graph/test/testLockfile.ts b/apps/lockfile-explorer/src/graph/test/testLockfile.ts index 88f898cbdb2..038133be61c 100644 --- a/apps/lockfile-explorer/src/graph/test/testLockfile.ts +++ b/apps/lockfile-explorer/src/graph/test/testLockfile.ts @@ -7,9 +7,11 @@ export const TEST_WORKSPACE: IJsonLfxWorkspace = { workspaceRootFullPath: '/repo', pnpmLockfilePath: 'common/temp/my-subspace/pnpm-lock.yaml', pnpmLockfileFolder: 'common/temp/my-subspace', + pnpmfilePath: 'common/temp/my-subspace/.pnpmfile.cjs', rushConfig: { rushVersion: '0.0.0', - subspaceName: 'my-subspace' + subspaceName: 'my-subspace', + rushPnpmfilePath: 'common/config/subspaces/my-subspace/.pnpmfile.cjs' } }; diff --git a/apps/lockfile-explorer/src/utils/init.ts b/apps/lockfile-explorer/src/utils/init.ts index 55f21779109..fe7bf5e8f2d 100644 --- a/apps/lockfile-explorer/src/utils/init.ts +++ b/apps/lockfile-explorer/src/utils/init.ts @@ -9,6 +9,7 @@ import { RushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration import type { Subspace } from '@microsoft/rush-lib/lib/api/Subspace'; import path from 'path'; +import * as lockfilePath from '../graph/lockfilePath'; import type { IAppState } from '../state'; export const init = (options: { @@ -31,25 +32,37 @@ export const init = (options: { const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); - const workspaceFolder: string = subspace.getSubspaceTempFolderPath(); + const commonTempFolder: string = subspace.getSubspaceTempFolderPath(); + const pnpmLockfileAbsolutePath: string = path.join(commonTempFolder, 'pnpm-lock.yaml'); + + const relativeCommonTempFolder: string = Path.convertToSlashes( + path.relative(currentFolder, subspace.getSubspaceTempFolderPath()) + ); + const pnpmLockfileRelativePath: string = lockfilePath.join(relativeCommonTempFolder, 'pnpm-lock.yaml'); + const pnpmFileRelativePath: string = lockfilePath.join(relativeCommonTempFolder, '.pnpmfile.cjs'); + + const relativeCommonConfigFolder: string = Path.convertToSlashes( + path.relative(currentFolder, subspace.getSubspaceConfigFolderPath()) + ); + const rushPnpmFileRelativePath: string = lockfilePath.join(relativeCommonConfigFolder, '.pnpmfile.cjs'); - const pnpmLockfileAbsolutePath: string = path.resolve(workspaceFolder, 'pnpm-lock.yaml'); - const pnpmLockfileRelativePath: string = path.relative(currentFolder, pnpmLockfileAbsolutePath); appState = { currentWorkingDirectory, appVersion, debugMode, lockfileExplorerProjectRoot, pnpmLockfileLocation: pnpmLockfileAbsolutePath, - pnpmfileLocation: workspaceFolder + '/.pnpmfile.cjs', + pnpmfileLocation: commonTempFolder + '/.pnpmfile.cjs', projectRoot: currentFolder, lfxWorkspace: { workspaceRootFullPath: currentFolder, pnpmLockfilePath: Path.convertToSlashes(pnpmLockfileRelativePath), pnpmLockfileFolder: Path.convertToSlashes(path.dirname(pnpmLockfileRelativePath)), + pnpmfilePath: Path.convertToSlashes(pnpmFileRelativePath), rushConfig: { rushVersion: rushConfiguration.rushConfigurationJson.rushVersion, - subspaceName: subspaceName ?? '' + subspaceName: subspaceName ?? '', + rushPnpmfilePath: rushPnpmFileRelativePath } } }; @@ -67,6 +80,7 @@ export const init = (options: { workspaceRootFullPath: currentFolder, pnpmLockfilePath: Path.convertToSlashes(path.relative(currentFolder, pnpmLockPath)), pnpmLockfileFolder: '', + pnpmfilePath: '.pnpmfile.cjs', rushConfig: undefined } }; diff --git a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush-pnpm.js b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush-pnpm.js index 2356649f4e7..4b7aad5d586 100644 --- a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush-pnpm.js +++ b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush-pnpm.js @@ -14,18 +14,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See the @microsoft/rush package's LICENSE file for details. -/******/ (() => { // webpackBootstrap -/******/ "use strict"; -var __webpack_exports__ = {}; -/*!*****************************************************!*\ +/******/ (() => { + // webpackBootstrap + /******/ 'use strict'; + var __webpack_exports__ = {}; + /*!*****************************************************!*\ !*** ./lib-esnext/scripts/install-run-rush-pnpm.js ***! \*****************************************************/ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. -require('./install-run-rush'); + // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. + // See LICENSE in the project root for license information. + require('./install-run-rush'); + //# sourceMappingURL=install-run-rush-pnpm.js.map + module.exports = __webpack_exports__; + /******/ +})(); //# sourceMappingURL=install-run-rush-pnpm.js.map -module.exports = __webpack_exports__; -/******/ })() -; -//# sourceMappingURL=install-run-rush-pnpm.js.map \ No newline at end of file diff --git a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush.js b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush.js index 9676fc718f9..48da5907f9d 100644 --- a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush.js +++ b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rush.js @@ -12,207 +12,234 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See the @microsoft/rush package's LICENSE file for details. -/******/ (() => { // webpackBootstrap -/******/ "use strict"; -/******/ var __webpack_modules__ = ({ - -/***/ 657147: -/*!*********************!*\ +/******/ (() => { + // webpackBootstrap + /******/ 'use strict'; + /******/ var __webpack_modules__ = { + /***/ 657147: + /*!*********************!*\ !*** external "fs" ***! \*********************/ -/***/ ((module) => { - -module.exports = require("fs"); + /***/ (module) => { + module.exports = require('fs'); -/***/ }), + /***/ + }, -/***/ 371017: -/*!***********************!*\ + /***/ 371017: + /*!***********************!*\ !*** external "path" ***! \***********************/ -/***/ ((module) => { - -module.exports = require("path"); + /***/ (module) => { + module.exports = require('path'); -/***/ }) + /***/ + } -/******/ }); -/************************************************************************/ -/******/ // The module cache -/******/ var __webpack_module_cache__ = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ // Check if module is in cache -/******/ var cachedModule = __webpack_module_cache__[moduleId]; -/******/ if (cachedModule !== undefined) { -/******/ return cachedModule.exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = __webpack_module_cache__[moduleId] = { -/******/ // no module.id needed -/******/ // no module.loaded needed -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/************************************************************************/ -/******/ /* webpack/runtime/compat get default export */ -/******/ (() => { -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = (module) => { -/******/ var getter = module && module.__esModule ? -/******/ () => (module['default']) : -/******/ () => (module); -/******/ __webpack_require__.d(getter, { a: getter }); -/******/ return getter; -/******/ }; -/******/ })(); -/******/ -/******/ /* webpack/runtime/define property getters */ -/******/ (() => { -/******/ // define getter functions for harmony exports -/******/ __webpack_require__.d = (exports, definition) => { -/******/ for(var key in definition) { -/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { -/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); -/******/ } -/******/ } -/******/ }; -/******/ })(); -/******/ -/******/ /* webpack/runtime/hasOwnProperty shorthand */ -/******/ (() => { -/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) -/******/ })(); -/******/ -/******/ /* webpack/runtime/make namespace object */ -/******/ (() => { -/******/ // define __esModule on exports -/******/ __webpack_require__.r = (exports) => { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ })(); -/******/ -/************************************************************************/ -var __webpack_exports__ = {}; -// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. -(() => { -/*!************************************************!*\ + /******/ + }; + /************************************************************************/ + /******/ // The module cache + /******/ var __webpack_module_cache__ = {}; + /******/ + /******/ // The require function + /******/ function __webpack_require__(moduleId) { + /******/ // Check if module is in cache + /******/ var cachedModule = __webpack_module_cache__[moduleId]; + /******/ if (cachedModule !== undefined) { + /******/ return cachedModule.exports; + /******/ + } + /******/ // Create a new module (and put it into the cache) + /******/ var module = (__webpack_module_cache__[moduleId] = { + /******/ // no module.id needed + /******/ // no module.loaded needed + /******/ exports: {} + /******/ + }); + /******/ + /******/ // Execute the module function + /******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); + /******/ + /******/ // Return the exports of the module + /******/ return module.exports; + /******/ + } + /******/ + /************************************************************************/ + /******/ /* webpack/runtime/compat get default export */ + /******/ (() => { + /******/ // getDefaultExport function for compatibility with non-harmony modules + /******/ __webpack_require__.n = (module) => { + /******/ var getter = + module && module.__esModule ? /******/ () => module['default'] : /******/ () => module; + /******/ __webpack_require__.d(getter, { a: getter }); + /******/ return getter; + /******/ + }; + /******/ + })(); + /******/ + /******/ /* webpack/runtime/define property getters */ + /******/ (() => { + /******/ // define getter functions for harmony exports + /******/ __webpack_require__.d = (exports, definition) => { + /******/ for (var key in definition) { + /******/ if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { + /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); + /******/ + } + /******/ + } + /******/ + }; + /******/ + })(); + /******/ + /******/ /* webpack/runtime/hasOwnProperty shorthand */ + /******/ (() => { + /******/ __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); + /******/ + })(); + /******/ + /******/ /* webpack/runtime/make namespace object */ + /******/ (() => { + /******/ // define __esModule on exports + /******/ __webpack_require__.r = (exports) => { + /******/ if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { + /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); + /******/ + } + /******/ Object.defineProperty(exports, '__esModule', { value: true }); + /******/ + }; + /******/ + })(); + /******/ + /************************************************************************/ + var __webpack_exports__ = {}; + // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. + (() => { + /*!************************************************!*\ !*** ./lib-esnext/scripts/install-run-rush.js ***! \************************************************/ -__webpack_require__.r(__webpack_exports__); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! path */ 371017); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 657147); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. -/* eslint-disable no-console */ - + __webpack_require__.r(__webpack_exports__); + /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! path */ 371017); + /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/ __webpack_require__.n( + path__WEBPACK_IMPORTED_MODULE_0__ + ); + /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 657147); + /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/ __webpack_require__.n( + fs__WEBPACK_IMPORTED_MODULE_1__ + ); + // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. + // See LICENSE in the project root for license information. + /* eslint-disable no-console */ -const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME, runWithErrorAndStatusCode } = require('./install-run'); -const PACKAGE_NAME = '@microsoft/rush'; -const RUSH_PREVIEW_VERSION = 'RUSH_PREVIEW_VERSION'; -const INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_RUSH_LOCKFILE_PATH'; -function _getRushVersion(logger) { - const rushPreviewVersion = process.env[RUSH_PREVIEW_VERSION]; - if (rushPreviewVersion !== undefined) { - logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); + const { + installAndRun, + findRushJsonFolder, + RUSH_JSON_FILENAME, + runWithErrorAndStatusCode + } = require('./install-run'); + const PACKAGE_NAME = '@microsoft/rush'; + const RUSH_PREVIEW_VERSION = 'RUSH_PREVIEW_VERSION'; + const INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_RUSH_LOCKFILE_PATH'; + function _getRushVersion(logger) { + const rushPreviewVersion = process.env[RUSH_PREVIEW_VERSION]; + if (rushPreviewVersion !== undefined) { + logger.info( + `Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}` + ); return rushPreviewVersion; - } - const rushJsonFolder = findRushJsonFolder(); - const rushJsonPath = path__WEBPACK_IMPORTED_MODULE_0__.join(rushJsonFolder, RUSH_JSON_FILENAME); - try { + } + const rushJsonFolder = findRushJsonFolder(); + const rushJsonPath = path__WEBPACK_IMPORTED_MODULE_0__.join(rushJsonFolder, RUSH_JSON_FILENAME); + try { const rushJsonContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(rushJsonPath, 'utf-8'); // Use a regular expression to parse out the rushVersion value because rush.json supports comments, // but JSON.parse does not and we don't want to pull in more dependencies than we need to in this script. const rushJsonMatches = rushJsonContents.match(/\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/); return rushJsonMatches[1]; - } - catch (e) { - throw new Error(`Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + + } catch (e) { + throw new Error( + `Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + `The 'rushVersion' field is either not assigned in ${RUSH_JSON_FILENAME} or was specified ` + - 'using an unexpected syntax.'); + 'using an unexpected syntax.' + ); + } } -} -function _getBin(scriptName) { - switch (scriptName.toLowerCase()) { + function _getBin(scriptName) { + switch (scriptName.toLowerCase()) { case 'install-run-rush-pnpm.js': - return 'rush-pnpm'; + return 'rush-pnpm'; case 'install-run-rushx.js': - return 'rushx'; + return 'rushx'; default: - return 'rush'; + return 'rush'; + } } -} -function _run() { - const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, ...packageBinArgs /* [build, --to, myproject] */] = process.argv; - // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the - // appropriate binary inside the rush package to run - const scriptName = path__WEBPACK_IMPORTED_MODULE_0__.basename(scriptPath); - const bin = _getBin(scriptName); - if (!nodePath || !scriptPath) { + function _run() { + const [ + nodePath /* Ex: /bin/node */, + scriptPath /* /repo/common/scripts/install-run-rush.js */, + ...packageBinArgs /* [build, --to, myproject] */ + ] = process.argv; + // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the + // appropriate binary inside the rush package to run + const scriptName = path__WEBPACK_IMPORTED_MODULE_0__.basename(scriptPath); + const bin = _getBin(scriptName); + if (!nodePath || !scriptPath) { throw new Error('Unexpected exception: could not detect node path or script path'); - } - let commandFound = false; - let logger = { info: console.log, error: console.error }; - for (const arg of packageBinArgs) { + } + let commandFound = false; + let logger = { info: console.log, error: console.error }; + for (const arg of packageBinArgs) { if (arg === '-q' || arg === '--quiet') { - // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress - // any normal informational/diagnostic information printed during startup. - // - // To maintain the same user experience, the install-run* scripts pass along this - // flag but also use it to suppress any diagnostic information normally printed - // to stdout. - logger = { - info: () => { }, - error: console.error - }; + // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress + // any normal informational/diagnostic information printed during startup. + // + // To maintain the same user experience, the install-run* scripts pass along this + // flag but also use it to suppress any diagnostic information normally printed + // to stdout. + logger = { + info: () => {}, + error: console.error + }; + } else if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { + // We either found something that looks like a command (i.e. - doesn't start with a "-"), + // or we found the -h/--help flag, which can be run without a command + commandFound = true; } - else if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { - // We either found something that looks like a command (i.e. - doesn't start with a "-"), - // or we found the -h/--help flag, which can be run without a command - commandFound = true; - } - } - if (!commandFound) { + } + if (!commandFound) { console.log(`Usage: ${scriptName} [args...]`); if (scriptName === 'install-run-rush-pnpm.js') { - console.log(`Example: ${scriptName} pnpm-command`); - } - else if (scriptName === 'install-run-rush.js') { - console.log(`Example: ${scriptName} build --to myproject`); - } - else { - console.log(`Example: ${scriptName} custom-command`); + console.log(`Example: ${scriptName} pnpm-command`); + } else if (scriptName === 'install-run-rush.js') { + console.log(`Example: ${scriptName} build --to myproject`); + } else { + console.log(`Example: ${scriptName} custom-command`); } process.exit(1); - } - runWithErrorAndStatusCode(logger, () => { + } + runWithErrorAndStatusCode(logger, () => { const version = _getRushVersion(logger); logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); const lockFilePath = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { - logger.info(`Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`); + logger.info( + `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.` + ); } return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); - }); -} -_run(); -//# sourceMappingURL=install-run-rush.js.map -})(); + }); + } + _run(); + //# sourceMappingURL=install-run-rush.js.map + })(); -module.exports = __webpack_exports__; -/******/ })() -; -//# sourceMappingURL=install-run-rush.js.map \ No newline at end of file + module.exports = __webpack_exports__; + /******/ +})(); +//# sourceMappingURL=install-run-rush.js.map diff --git a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rushx.js b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rushx.js index 6581521f3c7..f865303a384 100644 --- a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rushx.js +++ b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run-rushx.js @@ -14,18 +14,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See the @microsoft/rush package's LICENSE file for details. -/******/ (() => { // webpackBootstrap -/******/ "use strict"; -var __webpack_exports__ = {}; -/*!*************************************************!*\ +/******/ (() => { + // webpackBootstrap + /******/ 'use strict'; + var __webpack_exports__ = {}; + /*!*************************************************!*\ !*** ./lib-esnext/scripts/install-run-rushx.js ***! \*************************************************/ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. -require('./install-run-rush'); + // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. + // See LICENSE in the project root for license information. + require('./install-run-rush'); + //# sourceMappingURL=install-run-rushx.js.map + module.exports = __webpack_exports__; + /******/ +})(); //# sourceMappingURL=install-run-rushx.js.map -module.exports = __webpack_exports__; -/******/ })() -; -//# sourceMappingURL=install-run-rushx.js.map \ No newline at end of file diff --git a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run.js b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run.js index 9283c445267..580ebb343e9 100644 --- a/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run.js +++ b/build-tests/rush-redis-cobuild-plugin-integration-test/sandbox/sharded-repo/common/scripts/install-run.js @@ -12,732 +12,810 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See the @microsoft/rush package's LICENSE file for details. -/******/ (() => { // webpackBootstrap -/******/ "use strict"; -/******/ var __webpack_modules__ = ({ - -/***/ 679877: -/*!************************************************!*\ +/******/ (() => { + // webpackBootstrap + /******/ 'use strict'; + /******/ var __webpack_modules__ = { + /***/ 679877: + /*!************************************************!*\ !*** ./lib-esnext/utilities/npmrcUtilities.js ***! \************************************************/ -/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { - -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ "isVariableSetInNpmrcFile": () => (/* binding */ isVariableSetInNpmrcFile), -/* harmony export */ "syncNpmrc": () => (/* binding */ syncNpmrc) -/* harmony export */ }); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! fs */ 657147); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! path */ 371017); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. -// IMPORTANT - do not use any non-built-in libraries in this file - + /***/ (__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + __webpack_require__.r(__webpack_exports__); + /* harmony export */ __webpack_require__.d(__webpack_exports__, { + /* harmony export */ isVariableSetInNpmrcFile: () => /* binding */ isVariableSetInNpmrcFile, + /* harmony export */ syncNpmrc: () => /* binding */ syncNpmrc + /* harmony export */ + }); + /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! fs */ 657147); + /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = + /*#__PURE__*/ __webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__); + /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! path */ 371017); + /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = + /*#__PURE__*/ __webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); + // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. + // See LICENSE in the project root for license information. + // IMPORTANT - do not use any non-built-in libraries in this file -/** - * This function reads the content for given .npmrc file path, and also trims - * unusable lines from the .npmrc file. - * - * @returns - * The text of the the .npmrc. - */ -// create a global _combinedNpmrc for cache purpose -const _combinedNpmrcMap = new Map(); -function _trimNpmrcFile(options) { - const { sourceNpmrcPath, linesToPrepend, linesToAppend } = options; - const combinedNpmrcFromCache = _combinedNpmrcMap.get(sourceNpmrcPath); - if (combinedNpmrcFromCache !== undefined) { - return combinedNpmrcFromCache; - } - let npmrcFileLines = []; - if (linesToPrepend) { - npmrcFileLines.push(...linesToPrepend); - } - if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { - npmrcFileLines.push(...fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(sourceNpmrcPath).toString().split('\n')); - } - if (linesToAppend) { - npmrcFileLines.push(...linesToAppend); - } - npmrcFileLines = npmrcFileLines.map((line) => (line || '').trim()); - const resultLines = []; - // This finds environment variable tokens that look like "${VAR_NAME}" - const expansionRegExp = /\$\{([^\}]+)\}/g; - // Comment lines start with "#" or ";" - const commentRegExp = /^\s*[#;]/; - // Trim out lines that reference environment variables that aren't defined - for (let line of npmrcFileLines) { - let lineShouldBeTrimmed = false; - //remove spaces before or after key and value - line = line - .split('=') - .map((lineToTrim) => lineToTrim.trim()) - .join('='); - // Ignore comment lines - if (!commentRegExp.test(line)) { - const environmentVariables = line.match(expansionRegExp); - if (environmentVariables) { + /** + * This function reads the content for given .npmrc file path, and also trims + * unusable lines from the .npmrc file. + * + * @returns + * The text of the the .npmrc. + */ + // create a global _combinedNpmrc for cache purpose + const _combinedNpmrcMap = new Map(); + function _trimNpmrcFile(options) { + const { sourceNpmrcPath, linesToPrepend, linesToAppend } = options; + const combinedNpmrcFromCache = _combinedNpmrcMap.get(sourceNpmrcPath); + if (combinedNpmrcFromCache !== undefined) { + return combinedNpmrcFromCache; + } + let npmrcFileLines = []; + if (linesToPrepend) { + npmrcFileLines.push(...linesToPrepend); + } + if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { + npmrcFileLines.push( + ...fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(sourceNpmrcPath).toString().split('\n') + ); + } + if (linesToAppend) { + npmrcFileLines.push(...linesToAppend); + } + npmrcFileLines = npmrcFileLines.map((line) => (line || '').trim()); + const resultLines = []; + // This finds environment variable tokens that look like "${VAR_NAME}" + const expansionRegExp = /\$\{([^\}]+)\}/g; + // Comment lines start with "#" or ";" + const commentRegExp = /^\s*[#;]/; + // Trim out lines that reference environment variables that aren't defined + for (let line of npmrcFileLines) { + let lineShouldBeTrimmed = false; + //remove spaces before or after key and value + line = line + .split('=') + .map((lineToTrim) => lineToTrim.trim()) + .join('='); + // Ignore comment lines + if (!commentRegExp.test(line)) { + const environmentVariables = line.match(expansionRegExp); + if (environmentVariables) { for (const token of environmentVariables) { - // Remove the leading "${" and the trailing "}" from the token - const environmentVariableName = token.substring(2, token.length - 1); - // Is the environment variable defined? - if (!process.env[environmentVariableName]) { - // No, so trim this line - lineShouldBeTrimmed = true; - break; - } + // Remove the leading "${" and the trailing "}" from the token + const environmentVariableName = token.substring(2, token.length - 1); + // Is the environment variable defined? + if (!process.env[environmentVariableName]) { + // No, so trim this line + lineShouldBeTrimmed = true; + break; + } } + } } + if (lineShouldBeTrimmed) { + // Example output: + // "; MISSING ENVIRONMENT VARIABLE: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}" + resultLines.push('; MISSING ENVIRONMENT VARIABLE: ' + line); + } else { + resultLines.push(line); + } + } + const combinedNpmrc = resultLines.join('\n'); + //save the cache + _combinedNpmrcMap.set(sourceNpmrcPath, combinedNpmrc); + return combinedNpmrc; } - if (lineShouldBeTrimmed) { - // Example output: - // "; MISSING ENVIRONMENT VARIABLE: //my-registry.com/npm/:_authToken=${MY_AUTH_TOKEN}" - resultLines.push('; MISSING ENVIRONMENT VARIABLE: ' + line); - } - else { - resultLines.push(line); + function _copyAndTrimNpmrcFile(options) { + const { logger, sourceNpmrcPath, targetNpmrcPath, linesToPrepend, linesToAppend } = options; + logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose + logger.info(` --> "${targetNpmrcPath}"`); + const combinedNpmrc = _trimNpmrcFile({ + sourceNpmrcPath, + linesToPrepend, + linesToAppend + }); + fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(targetNpmrcPath, combinedNpmrc); + return combinedNpmrc; } - } - const combinedNpmrc = resultLines.join('\n'); - //save the cache - _combinedNpmrcMap.set(sourceNpmrcPath, combinedNpmrc); - return combinedNpmrc; -} -function _copyAndTrimNpmrcFile(options) { - const { logger, sourceNpmrcPath, targetNpmrcPath, linesToPrepend, linesToAppend } = options; - logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose - logger.info(` --> "${targetNpmrcPath}"`); - const combinedNpmrc = _trimNpmrcFile({ - sourceNpmrcPath, - linesToPrepend, - linesToAppend - }); - fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(targetNpmrcPath, combinedNpmrc); - return combinedNpmrc; -} -function syncNpmrc(options) { - const { sourceNpmrcFolder, targetNpmrcFolder, useNpmrcPublish, logger = { - // eslint-disable-next-line no-console - info: console.log, - // eslint-disable-next-line no-console - error: console.error - }, createIfMissing = false, linesToAppend, linesToPrepend } = options; - const sourceNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(sourceNpmrcFolder, !useNpmrcPublish ? '.npmrc' : '.npmrc-publish'); - const targetNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(targetNpmrcFolder, '.npmrc'); - try { - if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath) || createIfMissing) { - // Ensure the target folder exists - if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcFolder)) { + function syncNpmrc(options) { + const { + sourceNpmrcFolder, + targetNpmrcFolder, + useNpmrcPublish, + logger = { + // eslint-disable-next-line no-console + info: console.log, + // eslint-disable-next-line no-console + error: console.error + }, + createIfMissing = false, + linesToAppend, + linesToPrepend + } = options; + const sourceNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join( + sourceNpmrcFolder, + !useNpmrcPublish ? '.npmrc' : '.npmrc-publish' + ); + const targetNpmrcPath = path__WEBPACK_IMPORTED_MODULE_1__.join(targetNpmrcFolder, '.npmrc'); + try { + if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath) || createIfMissing) { + // Ensure the target folder exists + if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcFolder)) { fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(targetNpmrcFolder, { recursive: true }); - } - return _copyAndTrimNpmrcFile({ + } + return _copyAndTrimNpmrcFile({ sourceNpmrcPath, targetNpmrcPath, logger, linesToAppend, linesToPrepend - }); + }); + } else if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcPath)) { + // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target + logger.info(`Deleting ${targetNpmrcPath}`); // Verbose + fs__WEBPACK_IMPORTED_MODULE_0__.unlinkSync(targetNpmrcPath); + } + } catch (e) { + throw new Error(`Error syncing .npmrc file: ${e}`); + } } - else if (fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(targetNpmrcPath)) { - // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target - logger.info(`Deleting ${targetNpmrcPath}`); // Verbose - fs__WEBPACK_IMPORTED_MODULE_0__.unlinkSync(targetNpmrcPath); + function isVariableSetInNpmrcFile(sourceNpmrcFolder, variableKey) { + const sourceNpmrcPath = `${sourceNpmrcFolder}/.npmrc`; + //if .npmrc file does not exist, return false directly + if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { + return false; + } + const trimmedNpmrcFile = _trimNpmrcFile({ sourceNpmrcPath }); + const variableKeyRegExp = new RegExp(`^${variableKey}=`, 'm'); + return trimmedNpmrcFile.match(variableKeyRegExp) !== null; } - } - catch (e) { - throw new Error(`Error syncing .npmrc file: ${e}`); - } -} -function isVariableSetInNpmrcFile(sourceNpmrcFolder, variableKey) { - const sourceNpmrcPath = `${sourceNpmrcFolder}/.npmrc`; - //if .npmrc file does not exist, return false directly - if (!fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(sourceNpmrcPath)) { - return false; - } - const trimmedNpmrcFile = _trimNpmrcFile({ sourceNpmrcPath }); - const variableKeyRegExp = new RegExp(`^${variableKey}=`, 'm'); - return trimmedNpmrcFile.match(variableKeyRegExp) !== null; -} -//# sourceMappingURL=npmrcUtilities.js.map + //# sourceMappingURL=npmrcUtilities.js.map -/***/ }), + /***/ + }, -/***/ 532081: -/*!********************************!*\ + /***/ 532081: + /*!********************************!*\ !*** external "child_process" ***! \********************************/ -/***/ ((module) => { - -module.exports = require("child_process"); + /***/ (module) => { + module.exports = require('child_process'); -/***/ }), + /***/ + }, -/***/ 657147: -/*!*********************!*\ + /***/ 657147: + /*!*********************!*\ !*** external "fs" ***! \*********************/ -/***/ ((module) => { + /***/ (module) => { + module.exports = require('fs'); -module.exports = require("fs"); + /***/ + }, -/***/ }), - -/***/ 822037: -/*!*********************!*\ + /***/ 822037: + /*!*********************!*\ !*** external "os" ***! \*********************/ -/***/ ((module) => { - -module.exports = require("os"); + /***/ (module) => { + module.exports = require('os'); -/***/ }), + /***/ + }, -/***/ 371017: -/*!***********************!*\ + /***/ 371017: + /*!***********************!*\ !*** external "path" ***! \***********************/ -/***/ ((module) => { - -module.exports = require("path"); + /***/ (module) => { + module.exports = require('path'); -/***/ }) + /***/ + } -/******/ }); -/************************************************************************/ -/******/ // The module cache -/******/ var __webpack_module_cache__ = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ // Check if module is in cache -/******/ var cachedModule = __webpack_module_cache__[moduleId]; -/******/ if (cachedModule !== undefined) { -/******/ return cachedModule.exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = __webpack_module_cache__[moduleId] = { -/******/ // no module.id needed -/******/ // no module.loaded needed -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/************************************************************************/ -/******/ /* webpack/runtime/compat get default export */ -/******/ (() => { -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = (module) => { -/******/ var getter = module && module.__esModule ? -/******/ () => (module['default']) : -/******/ () => (module); -/******/ __webpack_require__.d(getter, { a: getter }); -/******/ return getter; -/******/ }; -/******/ })(); -/******/ -/******/ /* webpack/runtime/define property getters */ -/******/ (() => { -/******/ // define getter functions for harmony exports -/******/ __webpack_require__.d = (exports, definition) => { -/******/ for(var key in definition) { -/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { -/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); -/******/ } -/******/ } -/******/ }; -/******/ })(); -/******/ -/******/ /* webpack/runtime/hasOwnProperty shorthand */ -/******/ (() => { -/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) -/******/ })(); -/******/ -/******/ /* webpack/runtime/make namespace object */ -/******/ (() => { -/******/ // define __esModule on exports -/******/ __webpack_require__.r = (exports) => { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ })(); -/******/ -/************************************************************************/ -var __webpack_exports__ = {}; -// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. -(() => { -/*!*******************************************!*\ + /******/ + }; + /************************************************************************/ + /******/ // The module cache + /******/ var __webpack_module_cache__ = {}; + /******/ + /******/ // The require function + /******/ function __webpack_require__(moduleId) { + /******/ // Check if module is in cache + /******/ var cachedModule = __webpack_module_cache__[moduleId]; + /******/ if (cachedModule !== undefined) { + /******/ return cachedModule.exports; + /******/ + } + /******/ // Create a new module (and put it into the cache) + /******/ var module = (__webpack_module_cache__[moduleId] = { + /******/ // no module.id needed + /******/ // no module.loaded needed + /******/ exports: {} + /******/ + }); + /******/ + /******/ // Execute the module function + /******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); + /******/ + /******/ // Return the exports of the module + /******/ return module.exports; + /******/ + } + /******/ + /************************************************************************/ + /******/ /* webpack/runtime/compat get default export */ + /******/ (() => { + /******/ // getDefaultExport function for compatibility with non-harmony modules + /******/ __webpack_require__.n = (module) => { + /******/ var getter = + module && module.__esModule ? /******/ () => module['default'] : /******/ () => module; + /******/ __webpack_require__.d(getter, { a: getter }); + /******/ return getter; + /******/ + }; + /******/ + })(); + /******/ + /******/ /* webpack/runtime/define property getters */ + /******/ (() => { + /******/ // define getter functions for harmony exports + /******/ __webpack_require__.d = (exports, definition) => { + /******/ for (var key in definition) { + /******/ if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { + /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); + /******/ + } + /******/ + } + /******/ + }; + /******/ + })(); + /******/ + /******/ /* webpack/runtime/hasOwnProperty shorthand */ + /******/ (() => { + /******/ __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); + /******/ + })(); + /******/ + /******/ /* webpack/runtime/make namespace object */ + /******/ (() => { + /******/ // define __esModule on exports + /******/ __webpack_require__.r = (exports) => { + /******/ if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { + /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); + /******/ + } + /******/ Object.defineProperty(exports, '__esModule', { value: true }); + /******/ + }; + /******/ + })(); + /******/ + /************************************************************************/ + var __webpack_exports__ = {}; + // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. + (() => { + /*!*******************************************!*\ !*** ./lib-esnext/scripts/install-run.js ***! \*******************************************/ -__webpack_require__.r(__webpack_exports__); -/* harmony export */ __webpack_require__.d(__webpack_exports__, { -/* harmony export */ "RUSH_JSON_FILENAME": () => (/* binding */ RUSH_JSON_FILENAME), -/* harmony export */ "findRushJsonFolder": () => (/* binding */ findRushJsonFolder), -/* harmony export */ "getNpmPath": () => (/* binding */ getNpmPath), -/* harmony export */ "installAndRun": () => (/* binding */ installAndRun), -/* harmony export */ "runWithErrorAndStatusCode": () => (/* binding */ runWithErrorAndStatusCode) -/* harmony export */ }); -/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! child_process */ 532081); -/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(child_process__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 657147); -/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! os */ 822037); -/* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(os__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! path */ 371017); -/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); -/* harmony import */ var _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../utilities/npmrcUtilities */ 679877); -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. -/* eslint-disable no-console */ - - - - + __webpack_require__.r(__webpack_exports__); + /* harmony export */ __webpack_require__.d(__webpack_exports__, { + /* harmony export */ RUSH_JSON_FILENAME: () => /* binding */ RUSH_JSON_FILENAME, + /* harmony export */ findRushJsonFolder: () => /* binding */ findRushJsonFolder, + /* harmony export */ getNpmPath: () => /* binding */ getNpmPath, + /* harmony export */ installAndRun: () => /* binding */ installAndRun, + /* harmony export */ runWithErrorAndStatusCode: () => /* binding */ runWithErrorAndStatusCode + /* harmony export */ + }); + /* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( + /*! child_process */ 532081 + ); + /* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_0___default = + /*#__PURE__*/ __webpack_require__.n(child_process__WEBPACK_IMPORTED_MODULE_0__); + /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! fs */ 657147); + /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/ __webpack_require__.n( + fs__WEBPACK_IMPORTED_MODULE_1__ + ); + /* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! os */ 822037); + /* harmony import */ var os__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/ __webpack_require__.n( + os__WEBPACK_IMPORTED_MODULE_2__ + ); + /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! path */ 371017); + /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/ __webpack_require__.n( + path__WEBPACK_IMPORTED_MODULE_3__ + ); + /* harmony import */ var _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__( + /*! ../utilities/npmrcUtilities */ 679877 + ); + // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. + // See LICENSE in the project root for license information. + /* eslint-disable no-console */ -const RUSH_JSON_FILENAME = 'rush.json'; -const RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME = 'RUSH_TEMP_FOLDER'; -const INSTALL_RUN_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_LOCKFILE_PATH'; -const INSTALLED_FLAG_FILENAME = 'installed.flag'; -const NODE_MODULES_FOLDER_NAME = 'node_modules'; -const PACKAGE_JSON_FILENAME = 'package.json'; -/** - * Parse a package specifier (in the form of name\@version) into name and version parts. - */ -function _parsePackageSpecifier(rawPackageSpecifier) { - rawPackageSpecifier = (rawPackageSpecifier || '').trim(); - const separatorIndex = rawPackageSpecifier.lastIndexOf('@'); - let name; - let version = undefined; - if (separatorIndex === 0) { + const RUSH_JSON_FILENAME = 'rush.json'; + const RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME = 'RUSH_TEMP_FOLDER'; + const INSTALL_RUN_LOCKFILE_PATH_VARIABLE = 'INSTALL_RUN_LOCKFILE_PATH'; + const INSTALLED_FLAG_FILENAME = 'installed.flag'; + const NODE_MODULES_FOLDER_NAME = 'node_modules'; + const PACKAGE_JSON_FILENAME = 'package.json'; + /** + * Parse a package specifier (in the form of name\@version) into name and version parts. + */ + function _parsePackageSpecifier(rawPackageSpecifier) { + rawPackageSpecifier = (rawPackageSpecifier || '').trim(); + const separatorIndex = rawPackageSpecifier.lastIndexOf('@'); + let name; + let version = undefined; + if (separatorIndex === 0) { // The specifier starts with a scope and doesn't have a version specified name = rawPackageSpecifier; - } - else if (separatorIndex === -1) { + } else if (separatorIndex === -1) { // The specifier doesn't have a version name = rawPackageSpecifier; - } - else { + } else { name = rawPackageSpecifier.substring(0, separatorIndex); version = rawPackageSpecifier.substring(separatorIndex + 1); - } - if (!name) { + } + if (!name) { throw new Error(`Invalid package specifier: ${rawPackageSpecifier}`); - } - return { name, version }; -} -let _npmPath = undefined; -/** - * Get the absolute path to the npm executable - */ -function getNpmPath() { - if (!_npmPath) { + } + return { name, version }; + } + let _npmPath = undefined; + /** + * Get the absolute path to the npm executable + */ + function getNpmPath() { + if (!_npmPath) { try { - if (_isWindows()) { - // We're on Windows - const whereOutput = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('where npm', { stdio: [] }).toString(); - const lines = whereOutput.split(os__WEBPACK_IMPORTED_MODULE_2__.EOL).filter((line) => !!line); - // take the last result, we are looking for a .cmd command - // see https://github.com/microsoft/rushstack/issues/759 - _npmPath = lines[lines.length - 1]; - } - else { - // We aren't on Windows - assume we're on *NIX or Darwin - _npmPath = child_process__WEBPACK_IMPORTED_MODULE_0__.execSync('command -v npm', { stdio: [] }).toString(); - } - } - catch (e) { - throw new Error(`Unable to determine the path to the NPM tool: ${e}`); + if (_isWindows()) { + // We're on Windows + const whereOutput = child_process__WEBPACK_IMPORTED_MODULE_0__ + .execSync('where npm', { stdio: [] }) + .toString(); + const lines = whereOutput.split(os__WEBPACK_IMPORTED_MODULE_2__.EOL).filter((line) => !!line); + // take the last result, we are looking for a .cmd command + // see https://github.com/microsoft/rushstack/issues/759 + _npmPath = lines[lines.length - 1]; + } else { + // We aren't on Windows - assume we're on *NIX or Darwin + _npmPath = child_process__WEBPACK_IMPORTED_MODULE_0__ + .execSync('command -v npm', { stdio: [] }) + .toString(); + } + } catch (e) { + throw new Error(`Unable to determine the path to the NPM tool: ${e}`); } _npmPath = _npmPath.trim(); if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(_npmPath)) { - throw new Error('The NPM executable does not exist'); + throw new Error('The NPM executable does not exist'); } + } + return _npmPath; } - return _npmPath; -} -function _ensureFolder(folderPath) { - if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(folderPath)) { + function _ensureFolder(folderPath) { + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(folderPath)) { const parentDir = path__WEBPACK_IMPORTED_MODULE_3__.dirname(folderPath); _ensureFolder(parentDir); fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(folderPath); - } -} -/** - * Create missing directories under the specified base directory, and return the resolved directory. - * - * Does not support "." or ".." path segments. - * Assumes the baseFolder exists. - */ -function _ensureAndJoinPath(baseFolder, ...pathSegments) { - let joinedPath = baseFolder; - try { + } + } + /** + * Create missing directories under the specified base directory, and return the resolved directory. + * + * Does not support "." or ".." path segments. + * Assumes the baseFolder exists. + */ + function _ensureAndJoinPath(baseFolder, ...pathSegments) { + let joinedPath = baseFolder; + try { for (let pathSegment of pathSegments) { - pathSegment = pathSegment.replace(/[\\\/]/g, '+'); - joinedPath = path__WEBPACK_IMPORTED_MODULE_3__.join(joinedPath, pathSegment); - if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(joinedPath)) { - fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(joinedPath); - } + pathSegment = pathSegment.replace(/[\\\/]/g, '+'); + joinedPath = path__WEBPACK_IMPORTED_MODULE_3__.join(joinedPath, pathSegment); + if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(joinedPath)) { + fs__WEBPACK_IMPORTED_MODULE_1__.mkdirSync(joinedPath); + } } - } - catch (e) { - throw new Error(`Error building local installation folder (${path__WEBPACK_IMPORTED_MODULE_3__.join(baseFolder, ...pathSegments)}): ${e}`); - } - return joinedPath; -} -function _getRushTempFolder(rushCommonFolder) { - const rushTempFolder = process.env[RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME]; - if (rushTempFolder !== undefined) { + } catch (e) { + throw new Error( + `Error building local installation folder (${path__WEBPACK_IMPORTED_MODULE_3__.join(baseFolder, ...pathSegments)}): ${e}` + ); + } + return joinedPath; + } + function _getRushTempFolder(rushCommonFolder) { + const rushTempFolder = process.env[RUSH_TEMP_FOLDER_ENV_VARIABLE_NAME]; + if (rushTempFolder !== undefined) { _ensureFolder(rushTempFolder); return rushTempFolder; - } - else { + } else { return _ensureAndJoinPath(rushCommonFolder, 'temp'); - } -} -/** - * Compare version strings according to semantic versioning. - * Returns a positive integer if "a" is a later version than "b", - * a negative integer if "b" is later than "a", - * and 0 otherwise. - */ -function _compareVersionStrings(a, b) { - const aParts = a.split(/[.-]/); - const bParts = b.split(/[.-]/); - const numberOfParts = Math.max(aParts.length, bParts.length); - for (let i = 0; i < numberOfParts; i++) { + } + } + /** + * Compare version strings according to semantic versioning. + * Returns a positive integer if "a" is a later version than "b", + * a negative integer if "b" is later than "a", + * and 0 otherwise. + */ + function _compareVersionStrings(a, b) { + const aParts = a.split(/[.-]/); + const bParts = b.split(/[.-]/); + const numberOfParts = Math.max(aParts.length, bParts.length); + for (let i = 0; i < numberOfParts; i++) { if (aParts[i] !== bParts[i]) { - return (Number(aParts[i]) || 0) - (Number(bParts[i]) || 0); + return (Number(aParts[i]) || 0) - (Number(bParts[i]) || 0); } - } - return 0; -} -/** - * Resolve a package specifier to a static version - */ -function _resolvePackageVersion(logger, rushCommonFolder, { name, version }) { - if (!version) { + } + return 0; + } + /** + * Resolve a package specifier to a static version + */ + function _resolvePackageVersion(logger, rushCommonFolder, { name, version }) { + if (!version) { version = '*'; // If no version is specified, use the latest version - } - if (version.match(/^[a-zA-Z0-9\-\+\.]+$/)) { + } + if (version.match(/^[a-zA-Z0-9\-\+\.]+$/)) { // If the version contains only characters that we recognize to be used in static version specifiers, // pass the version through return version; - } - else { + } else { // version resolves to try { - const rushTempFolder = _getRushTempFolder(rushCommonFolder); - const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush'); - (0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ - sourceNpmrcFolder, - targetNpmrcFolder: rushTempFolder, - logger - }); - const npmPath = getNpmPath(); - // This returns something that looks like: - // ``` - // [ - // "3.0.0", - // "3.0.1", - // ... - // "3.0.20" - // ] - // ``` - // - // if multiple versions match the selector, or - // - // ``` - // "3.0.0" - // ``` - // - // if only a single version matches. - const spawnSyncOptions = { - cwd: rushTempFolder, - stdio: [], - shell: _isWindows() - }; - const platformNpmPath = _getPlatformPath(npmPath); - const npmVersionSpawnResult = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, ['view', `${name}@${version}`, 'version', '--no-update-notifier', '--json'], spawnSyncOptions); - if (npmVersionSpawnResult.status !== 0) { - throw new Error(`"npm view" returned error code ${npmVersionSpawnResult.status}`); - } - const npmViewVersionOutput = npmVersionSpawnResult.stdout.toString(); - const parsedVersionOutput = JSON.parse(npmViewVersionOutput); - const versions = Array.isArray(parsedVersionOutput) - ? parsedVersionOutput - : [parsedVersionOutput]; - let latestVersion = versions[0]; - for (let i = 1; i < versions.length; i++) { - const latestVersionCandidate = versions[i]; - if (_compareVersionStrings(latestVersionCandidate, latestVersion) > 0) { - latestVersion = latestVersionCandidate; - } - } - if (!latestVersion) { - throw new Error('No versions found for the specified version range.'); + const rushTempFolder = _getRushTempFolder(rushCommonFolder); + const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join( + rushCommonFolder, + 'config', + 'rush' + ); + (0, _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ + sourceNpmrcFolder, + targetNpmrcFolder: rushTempFolder, + logger + }); + const npmPath = getNpmPath(); + // This returns something that looks like: + // ``` + // [ + // "3.0.0", + // "3.0.1", + // ... + // "3.0.20" + // ] + // ``` + // + // if multiple versions match the selector, or + // + // ``` + // "3.0.0" + // ``` + // + // if only a single version matches. + const spawnSyncOptions = { + cwd: rushTempFolder, + stdio: [], + shell: _isWindows() + }; + const platformNpmPath = _getPlatformPath(npmPath); + const npmVersionSpawnResult = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync( + platformNpmPath, + ['view', `${name}@${version}`, 'version', '--no-update-notifier', '--json'], + spawnSyncOptions + ); + if (npmVersionSpawnResult.status !== 0) { + throw new Error(`"npm view" returned error code ${npmVersionSpawnResult.status}`); + } + const npmViewVersionOutput = npmVersionSpawnResult.stdout.toString(); + const parsedVersionOutput = JSON.parse(npmViewVersionOutput); + const versions = Array.isArray(parsedVersionOutput) ? parsedVersionOutput : [parsedVersionOutput]; + let latestVersion = versions[0]; + for (let i = 1; i < versions.length; i++) { + const latestVersionCandidate = versions[i]; + if (_compareVersionStrings(latestVersionCandidate, latestVersion) > 0) { + latestVersion = latestVersionCandidate; } - return latestVersion; - } - catch (e) { - throw new Error(`Unable to resolve version ${version} of package ${name}: ${e}`); + } + if (!latestVersion) { + throw new Error('No versions found for the specified version range.'); + } + return latestVersion; + } catch (e) { + throw new Error(`Unable to resolve version ${version} of package ${name}: ${e}`); } - } -} -let _rushJsonFolder; -/** - * Find the absolute path to the folder containing rush.json - */ -function findRushJsonFolder() { - if (!_rushJsonFolder) { + } + } + let _rushJsonFolder; + /** + * Find the absolute path to the folder containing rush.json + */ + function findRushJsonFolder() { + if (!_rushJsonFolder) { let basePath = __dirname; let tempPath = __dirname; do { - const testRushJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(basePath, RUSH_JSON_FILENAME); - if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(testRushJsonPath)) { - _rushJsonFolder = basePath; - break; - } - else { - basePath = tempPath; - } + const testRushJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(basePath, RUSH_JSON_FILENAME); + if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(testRushJsonPath)) { + _rushJsonFolder = basePath; + break; + } else { + basePath = tempPath; + } } while (basePath !== (tempPath = path__WEBPACK_IMPORTED_MODULE_3__.dirname(basePath))); // Exit the loop when we hit the disk root if (!_rushJsonFolder) { - throw new Error(`Unable to find ${RUSH_JSON_FILENAME}.`); + throw new Error(`Unable to find ${RUSH_JSON_FILENAME}.`); } - } - return _rushJsonFolder; -} -/** - * Detects if the package in the specified directory is installed - */ -function _isPackageAlreadyInstalled(packageInstallFolder) { - try { - const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME); + } + return _rushJsonFolder; + } + /** + * Detects if the package in the specified directory is installed + */ + function _isPackageAlreadyInstalled(packageInstallFolder) { + try { + const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join( + packageInstallFolder, + INSTALLED_FLAG_FILENAME + ); if (!fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(flagFilePath)) { - return false; + return false; } const fileContents = fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(flagFilePath).toString(); return fileContents.trim() === process.version; - } - catch (e) { + } catch (e) { return false; + } } -} -/** - * Delete a file. Fail silently if it does not exist. - */ -function _deleteFile(file) { - try { + /** + * Delete a file. Fail silently if it does not exist. + */ + function _deleteFile(file) { + try { fs__WEBPACK_IMPORTED_MODULE_1__.unlinkSync(file); - } - catch (err) { + } catch (err) { if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { - throw err; + throw err; } - } -} -/** - * Removes the following files and directories under the specified folder path: - * - installed.flag - * - - * - node_modules - */ -function _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath) { - try { - const flagFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, INSTALLED_FLAG_FILENAME); + } + } + /** + * Removes the following files and directories under the specified folder path: + * - installed.flag + * - + * - node_modules + */ + function _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath) { + try { + const flagFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve( + packageInstallFolder, + INSTALLED_FLAG_FILENAME + ); _deleteFile(flagFile); - const packageLockFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, 'package-lock.json'); + const packageLockFile = path__WEBPACK_IMPORTED_MODULE_3__.resolve( + packageInstallFolder, + 'package-lock.json' + ); if (lockFilePath) { - fs__WEBPACK_IMPORTED_MODULE_1__.copyFileSync(lockFilePath, packageLockFile); - } - else { - // Not running `npm ci`, so need to cleanup - _deleteFile(packageLockFile); - const nodeModulesFolder = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME); - if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(nodeModulesFolder)) { - const rushRecyclerFolder = _ensureAndJoinPath(rushTempFolder, 'rush-recycler'); - fs__WEBPACK_IMPORTED_MODULE_1__.renameSync(nodeModulesFolder, path__WEBPACK_IMPORTED_MODULE_3__.join(rushRecyclerFolder, `install-run-${Date.now().toString()}`)); - } + fs__WEBPACK_IMPORTED_MODULE_1__.copyFileSync(lockFilePath, packageLockFile); + } else { + // Not running `npm ci`, so need to cleanup + _deleteFile(packageLockFile); + const nodeModulesFolder = path__WEBPACK_IMPORTED_MODULE_3__.resolve( + packageInstallFolder, + NODE_MODULES_FOLDER_NAME + ); + if (fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(nodeModulesFolder)) { + const rushRecyclerFolder = _ensureAndJoinPath(rushTempFolder, 'rush-recycler'); + fs__WEBPACK_IMPORTED_MODULE_1__.renameSync( + nodeModulesFolder, + path__WEBPACK_IMPORTED_MODULE_3__.join( + rushRecyclerFolder, + `install-run-${Date.now().toString()}` + ) + ); + } } - } - catch (e) { + } catch (e) { throw new Error(`Error cleaning the package install folder (${packageInstallFolder}): ${e}`); + } } -} -function _createPackageJson(packageInstallFolder, name, version) { - try { + function _createPackageJson(packageInstallFolder, name, version) { + try { const packageJsonContents = { - name: 'ci-rush', - version: '0.0.0', - dependencies: { - [name]: version - }, - description: "DON'T WARN", - repository: "DON'T WARN", - license: 'MIT' + name: 'ci-rush', + version: '0.0.0', + dependencies: { + [name]: version + }, + description: "DON'T WARN", + repository: "DON'T WARN", + license: 'MIT' }; - const packageJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, PACKAGE_JSON_FILENAME); - fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(packageJsonPath, JSON.stringify(packageJsonContents, undefined, 2)); - } - catch (e) { + const packageJsonPath = path__WEBPACK_IMPORTED_MODULE_3__.join( + packageInstallFolder, + PACKAGE_JSON_FILENAME + ); + fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync( + packageJsonPath, + JSON.stringify(packageJsonContents, undefined, 2) + ); + } catch (e) { throw new Error(`Unable to create package.json: ${e}`); + } } -} -/** - * Run "npm install" in the package install folder. - */ -function _installPackage(logger, packageInstallFolder, name, version, command) { - try { + /** + * Run "npm install" in the package install folder. + */ + function _installPackage(logger, packageInstallFolder, name, version, command) { + try { logger.info(`Installing ${name}...`); const npmPath = getNpmPath(); const platformNpmPath = _getPlatformPath(npmPath); const result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformNpmPath, [command], { - stdio: 'inherit', - cwd: packageInstallFolder, - env: process.env, - shell: _isWindows() + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env, + shell: _isWindows() }); if (result.status !== 0) { - throw new Error(`"npm ${command}" encountered an error`); + throw new Error(`"npm ${command}" encountered an error`); } logger.info(`Successfully installed ${name}@${version}`); - } - catch (e) { + } catch (e) { throw new Error(`Unable to install package: ${e}`); - } -} -/** - * Get the ".bin" path for the package. - */ -function _getBinPath(packageInstallFolder, binName) { - const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); - const resolvedBinName = _isWindows() ? `${binName}.cmd` : binName; - return path__WEBPACK_IMPORTED_MODULE_3__.resolve(binFolderPath, resolvedBinName); -} -/** - * Returns a cross-platform path - windows must enclose any path containing spaces within double quotes. - */ -function _getPlatformPath(platformPath) { - return _isWindows() && platformPath.includes(' ') ? `"${platformPath}"` : platformPath; -} -function _isWindows() { - return os__WEBPACK_IMPORTED_MODULE_2__.platform() === 'win32'; -} -/** - * Write a flag file to the package's install directory, signifying that the install was successful. - */ -function _writeFlagFile(packageInstallFolder) { - try { - const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join(packageInstallFolder, INSTALLED_FLAG_FILENAME); + } + } + /** + * Get the ".bin" path for the package. + */ + function _getBinPath(packageInstallFolder, binName) { + const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve( + packageInstallFolder, + NODE_MODULES_FOLDER_NAME, + '.bin' + ); + const resolvedBinName = _isWindows() ? `${binName}.cmd` : binName; + return path__WEBPACK_IMPORTED_MODULE_3__.resolve(binFolderPath, resolvedBinName); + } + /** + * Returns a cross-platform path - windows must enclose any path containing spaces within double quotes. + */ + function _getPlatformPath(platformPath) { + return _isWindows() && platformPath.includes(' ') ? `"${platformPath}"` : platformPath; + } + function _isWindows() { + return os__WEBPACK_IMPORTED_MODULE_2__.platform() === 'win32'; + } + /** + * Write a flag file to the package's install directory, signifying that the install was successful. + */ + function _writeFlagFile(packageInstallFolder) { + try { + const flagFilePath = path__WEBPACK_IMPORTED_MODULE_3__.join( + packageInstallFolder, + INSTALLED_FLAG_FILENAME + ); fs__WEBPACK_IMPORTED_MODULE_1__.writeFileSync(flagFilePath, process.version); - } - catch (e) { + } catch (e) { throw new Error(`Unable to create installed.flag file in ${packageInstallFolder}`); - } -} -function installAndRun(logger, packageName, packageVersion, packageBinName, packageBinArgs, lockFilePath = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE]) { - const rushJsonFolder = findRushJsonFolder(); - const rushCommonFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushJsonFolder, 'common'); - const rushTempFolder = _getRushTempFolder(rushCommonFolder); - const packageInstallFolder = _ensureAndJoinPath(rushTempFolder, 'install-run', `${packageName}@${packageVersion}`); - if (!_isPackageAlreadyInstalled(packageInstallFolder)) { + } + } + function installAndRun( + logger, + packageName, + packageVersion, + packageBinName, + packageBinArgs, + lockFilePath = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE] + ) { + const rushJsonFolder = findRushJsonFolder(); + const rushCommonFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushJsonFolder, 'common'); + const rushTempFolder = _getRushTempFolder(rushCommonFolder); + const packageInstallFolder = _ensureAndJoinPath( + rushTempFolder, + 'install-run', + `${packageName}@${packageVersion}` + ); + if (!_isPackageAlreadyInstalled(packageInstallFolder)) { // The package isn't already installed _cleanInstallFolder(rushTempFolder, packageInstallFolder, lockFilePath); const sourceNpmrcFolder = path__WEBPACK_IMPORTED_MODULE_3__.join(rushCommonFolder, 'config', 'rush'); - (0,_utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ - sourceNpmrcFolder, - targetNpmrcFolder: packageInstallFolder, - logger + (0, _utilities_npmrcUtilities__WEBPACK_IMPORTED_MODULE_4__.syncNpmrc)({ + sourceNpmrcFolder, + targetNpmrcFolder: packageInstallFolder, + logger }); _createPackageJson(packageInstallFolder, packageName, packageVersion); const command = lockFilePath ? 'ci' : 'install'; _installPackage(logger, packageInstallFolder, packageName, packageVersion, command); _writeFlagFile(packageInstallFolder); - } - const statusMessage = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; - const statusMessageLine = new Array(statusMessage.length + 1).join('-'); - logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); - const binPath = _getBinPath(packageInstallFolder, packageBinName); - const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); - // Windows environment variables are case-insensitive. Instead of using SpawnSyncOptions.env, we need to - // assign via the process.env proxy to ensure that we append to the right PATH key. - const originalEnvPath = process.env.PATH || ''; - let result; - try { + } + const statusMessage = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; + const statusMessageLine = new Array(statusMessage.length + 1).join('-'); + logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + const binPath = _getBinPath(packageInstallFolder, packageBinName); + const binFolderPath = path__WEBPACK_IMPORTED_MODULE_3__.resolve( + packageInstallFolder, + NODE_MODULES_FOLDER_NAME, + '.bin' + ); + // Windows environment variables are case-insensitive. Instead of using SpawnSyncOptions.env, we need to + // assign via the process.env proxy to ensure that we append to the right PATH key. + const originalEnvPath = process.env.PATH || ''; + let result; + try { // `npm` bin stubs on Windows are `.cmd` files // Node.js will not directly invoke a `.cmd` file unless `shell` is set to `true` const platformBinPath = _getPlatformPath(binPath); process.env.PATH = [binFolderPath, originalEnvPath].join(path__WEBPACK_IMPORTED_MODULE_3__.delimiter); result = child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync(platformBinPath, packageBinArgs, { - stdio: 'inherit', - windowsVerbatimArguments: false, - shell: _isWindows(), - cwd: process.cwd(), - env: process.env + stdio: 'inherit', + windowsVerbatimArguments: false, + shell: _isWindows(), + cwd: process.cwd(), + env: process.env }); - } - finally { + } finally { process.env.PATH = originalEnvPath; - } - if (result.status !== null) { + } + if (result.status !== null) { return result.status; - } - else { + } else { throw result.error || new Error('An unknown error occurred.'); + } } -} -function runWithErrorAndStatusCode(logger, fn) { - process.exitCode = 1; - try { + function runWithErrorAndStatusCode(logger, fn) { + process.exitCode = 1; + try { const exitCode = fn(); process.exitCode = exitCode; - } - catch (e) { + } catch (e) { logger.error('\n\n' + e.toString() + '\n\n'); - } -} -function _run() { - const [nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, rawPackageSpecifier /* qrcode@^1.2.0 */, packageBinName /* qrcode */, ...packageBinArgs /* [-f, myproject/lib] */] = process.argv; - if (!nodePath) { + } + } + function _run() { + const [ + nodePath /* Ex: /bin/node */, + scriptPath /* /repo/common/scripts/install-run-rush.js */, + rawPackageSpecifier /* qrcode@^1.2.0 */, + packageBinName /* qrcode */, + ...packageBinArgs /* [-f, myproject/lib] */ + ] = process.argv; + if (!nodePath) { throw new Error('Unexpected exception: could not detect node path'); - } - if (path__WEBPACK_IMPORTED_MODULE_3__.basename(scriptPath).toLowerCase() !== 'install-run.js') { + } + if (path__WEBPACK_IMPORTED_MODULE_3__.basename(scriptPath).toLowerCase() !== 'install-run.js') { // If install-run.js wasn't directly invoked, don't execute the rest of this function. Return control // to the script that (presumably) imported this file return; - } - if (process.argv.length < 4) { + } + if (process.argv.length < 4) { console.log('Usage: install-run.js @ [args...]'); console.log('Example: install-run.js qrcode@1.2.2 qrcode https://rushjs.io'); process.exit(1); - } - const logger = { info: console.log, error: console.error }; - runWithErrorAndStatusCode(logger, () => { + } + const logger = { info: console.log, error: console.error }; + runWithErrorAndStatusCode(logger, () => { const rushJsonFolder = findRushJsonFolder(); const rushCommonFolder = _ensureAndJoinPath(rushJsonFolder, 'common'); const packageSpecifier = _parsePackageSpecifier(rawPackageSpecifier); const name = packageSpecifier.name; const version = _resolvePackageVersion(logger, rushCommonFolder, packageSpecifier); if (packageSpecifier.version !== version) { - console.log(`Resolved to ${name}@${version}`); + console.log(`Resolved to ${name}@${version}`); } return installAndRun(logger, name, version, packageBinName, packageBinArgs); - }); -} -_run(); -//# sourceMappingURL=install-run.js.map -})(); + }); + } + _run(); + //# sourceMappingURL=install-run.js.map + })(); -module.exports = __webpack_exports__; -/******/ })() -; -//# sourceMappingURL=install-run.js.map \ No newline at end of file + module.exports = __webpack_exports__; + /******/ +})(); +//# sourceMappingURL=install-run.js.map diff --git a/common/autoinstallers/rush-prettier/package.json b/common/autoinstallers/rush-prettier/package.json index fbe65e10c09..2b3a8597862 100644 --- a/common/autoinstallers/rush-prettier/package.json +++ b/common/autoinstallers/rush-prettier/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "pretty-quick": "4.0.0", - "prettier": "3.2.5" + "pretty-quick": "4.2.2", + "prettier": "3.6.2" } } diff --git a/common/autoinstallers/rush-prettier/pnpm-lock.yaml b/common/autoinstallers/rush-prettier/pnpm-lock.yaml index f5ea674ea1c..94e04d1885c 100644 --- a/common/autoinstallers/rush-prettier/pnpm-lock.yaml +++ b/common/autoinstallers/rush-prettier/pnpm-lock.yaml @@ -6,195 +6,65 @@ settings: dependencies: prettier: - specifier: 3.2.5 - version: 3.2.5 + specifier: 3.6.2 + version: 3.6.2 pretty-quick: - specifier: 4.0.0 - version: 4.0.0(prettier@3.2.5) + specifier: 4.2.2 + version: 4.2.2(prettier@3.6.2) packages: - /cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - dev: false - - /execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - dev: false - - /find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 + /@pkgr/core@0.2.9: + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} dev: false - /get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - dev: false - - /human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - dev: false - - /ignore@5.3.1: - resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + /ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} dev: false - /is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - dev: false - - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: false - - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - dependencies: - p-locate: 5.0.0 - dev: false - - /merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - dev: false - - /mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - dev: false - /mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} dev: false - /npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - dependencies: - path-key: 3.1.1 + /picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: false - /onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - dependencies: - mimic-fn: 2.1.0 + /picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} dev: false - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - dependencies: - yocto-queue: 0.1.0 - dev: false - - /p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - dependencies: - p-limit: 3.1.0 - dev: false - - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - dev: false - - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: false - - /picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - dev: false - - /picomatch@3.0.1: - resolution: {integrity: sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==} - engines: {node: '>=10'} - dev: false - - /prettier@3.2.5: - resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + /prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true dev: false - /pretty-quick@4.0.0(prettier@3.2.5): - resolution: {integrity: sha512-M+2MmeufXb/M7Xw3Afh1gxcYpj+sK0AxEfnfF958ktFeAyi5MsKY5brymVURQLgPLV1QaF5P4pb2oFJ54H3yzQ==} + /pretty-quick@4.2.2(prettier@3.6.2): + resolution: {integrity: sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w==} engines: {node: '>=14'} hasBin: true peerDependencies: prettier: ^3.0.0 dependencies: - execa: 5.1.1 - find-up: 5.0.0 - ignore: 5.3.1 + '@pkgr/core': 0.2.9 + ignore: 7.0.5 mri: 1.2.0 - picocolors: 1.0.1 - picomatch: 3.0.1 - prettier: 3.2.5 - tslib: 2.6.2 + picocolors: 1.1.1 + picomatch: 4.0.3 + prettier: 3.6.2 + tinyexec: 0.3.2 + tslib: 2.8.1 dev: false - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - dependencies: - shebang-regex: 3.0.0 - dev: false - - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - dev: false - - /signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: false - - /strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - dev: false - - /tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: false - - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - dependencies: - isexe: 2.0.0 + /tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} dev: false - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: false diff --git a/common/changes/@microsoft/rush/octogonz-lfx-fixes4_2025-09-21-19-46.json b/common/changes/@microsoft/rush/octogonz-lfx-fixes4_2025-09-21-19-46.json new file mode 100644 index 00000000000..bd7ff97cb34 --- /dev/null +++ b/common/changes/@microsoft/rush/octogonz-lfx-fixes4_2025-09-21-19-46.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft-isolated-typescript-transpile-plugin/octogonz-lfx-fixes4_2025-09-21-19-46.json b/common/changes/@rushstack/heft-isolated-typescript-transpile-plugin/octogonz-lfx-fixes4_2025-09-21-19-46.json new file mode 100644 index 00000000000..fbd4c12b6b9 --- /dev/null +++ b/common/changes/@rushstack/heft-isolated-typescript-transpile-plugin/octogonz-lfx-fixes4_2025-09-21-19-46.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-isolated-typescript-transpile-plugin", + "comment": "", + "type": "none" + } + ], + "packageName": "@rushstack/heft-isolated-typescript-transpile-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-57.json b/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-57.json new file mode 100644 index 00000000000..873badc1a71 --- /dev/null +++ b/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-57.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lockfile-explorer", + "comment": "Add syntax highlighter", + "type": "minor" + } + ], + "packageName": "@rushstack/lockfile-explorer" +} \ No newline at end of file diff --git a/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-58.json b/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-58.json new file mode 100644 index 00000000000..8c6e6ab35a0 --- /dev/null +++ b/common/changes/@rushstack/lockfile-explorer/octogonz-lfx-fixes4_2025-09-17-17-58.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lockfile-explorer", + "comment": "Isolate .pnpmcfile.cjs evaluation", + "type": "minor" + } + ], + "packageName": "@rushstack/lockfile-explorer" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 15e248ddeba..103baa6d943 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -62,6 +62,10 @@ "name": "office-ui-fabric-core", "allowedCategories": [ "libraries" ] }, + { + "name": "prism-react-renderer", + "allowedCategories": [ "libraries" ] + }, { "name": "react", "allowedCategories": [ "libraries", "tests", "vscode-extensions" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index a7daffa4a97..fa35e21fefc 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -275,6 +275,9 @@ importers: '@rushstack/rush-themed-ui': specifier: workspace:* version: link:../../libraries/rush-themed-ui + prism-react-renderer: + specifier: ~2.4.1 + version: 2.4.1(react@17.0.2) react: specifier: ~17.0.2 version: 17.0.2 @@ -14045,6 +14048,10 @@ packages: resolution: {integrity: sha512-nj39q0wAIdhwn7DGUyT9irmsKK1tV0bd5WFEhgpqNTMFZ8cE+jieuTphCW0tfdm47S2zVT5mr09B28b1chmQMA==} dev: true + /@types/prismjs@1.26.5: + resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + dev: false + /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} @@ -17245,6 +17252,11 @@ packages: engines: {node: '>=6'} dev: true + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + dev: false + /cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -26152,6 +26164,16 @@ packages: engines: {node: '>= 0.8'} dev: true + /prism-react-renderer@2.4.1(react@17.0.2): + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + dependencies: + '@types/prismjs': 1.26.5 + clsx: 2.1.1 + react: 17.0.2 + dev: false + /prismjs@1.27.0: resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} engines: {node: '>=6'} diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 280c8df4e79..711e482da42 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "260e89de9a23ec7f38ec7956133ae1097057004b", + "pnpmShrinkwrapHash": "b3b0018c5869d606a645e2b69ef6c53f9d2bf483", "preferredVersionsHash": "61cd419c533464b580f653eb5f5a7e27fe7055ca" } diff --git a/heft-plugins/heft-isolated-typescript-transpile-plugin/src/SwcIsolatedTranspilePlugin.ts b/heft-plugins/heft-isolated-typescript-transpile-plugin/src/SwcIsolatedTranspilePlugin.ts index 4d281846216..4ac859ef33c 100644 --- a/heft-plugins/heft-isolated-typescript-transpile-plugin/src/SwcIsolatedTranspilePlugin.ts +++ b/heft-plugins/heft-isolated-typescript-transpile-plugin/src/SwcIsolatedTranspilePlugin.ts @@ -290,10 +290,10 @@ async function transpileProjectAsync( // https://github.com/swc-project/swc-node/blob/e6cd8b83d1ce76a0abf770f52425704e5d2872c6/packages/register/read-default-tsconfig.ts#L131C7-L139C20 const react: Partial | undefined = - tsConfigOptions.jsxFactory ?? + (tsConfigOptions.jsxFactory ?? tsConfigOptions.jsxFragmentFactory ?? tsConfigOptions.jsx ?? - tsConfigOptions.jsxImportSource + tsConfigOptions.jsxImportSource) ? { pragma: tsConfigOptions.jsxFactory, pragmaFrag: tsConfigOptions.jsxFragmentFactory, diff --git a/libraries/rush-lib/src/api/VersionPolicy.ts b/libraries/rush-lib/src/api/VersionPolicy.ts index f2a18967836..8d357bbaeb7 100644 --- a/libraries/rush-lib/src/api/VersionPolicy.ts +++ b/libraries/rush-lib/src/api/VersionPolicy.ts @@ -218,7 +218,7 @@ export class LockStepVersionPolicy extends VersionPolicy { /** * @internal */ - public declare readonly _json: ILockStepVersionJson; + declare public readonly _json: ILockStepVersionJson; private _version: semver.SemVer; /** @@ -340,7 +340,7 @@ export class IndividualVersionPolicy extends VersionPolicy { /** * @internal */ - public declare readonly _json: IIndividualVersionJson; + declare public readonly _json: IIndividualVersionJson; /** * The major version that has been locked diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/inconsistent-dep-devDep.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/inconsistent-dep-devDep.yaml index 7f26989631c..08f6420eaf5 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/inconsistent-dep-devDep.yaml +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/inconsistent-dep-devDep.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false importers: - .: {} ../../apps/bar: @@ -15,12 +14,13 @@ importers: version: 2.3.2 packages: - prettier@2.3.2: - resolution: {integrity: sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==} - engines: {node: '>=10.13.0'} + resolution: + { + integrity: sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ== + } + engines: { node: '>=10.13.0' } hasBin: true snapshots: - prettier@2.3.2: {} diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/modified.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/modified.yaml index 71509e89efe..7f156d05a28 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/modified.yaml +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/modified.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false importers: - .: {} ../../apps/foo: @@ -19,17 +18,21 @@ importers: version: 5.0.4 packages: - tslib@2.3.1: - resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} + resolution: + { + integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + } typescript@5.0.4: - resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} - engines: {node: '>=12.20'} + resolution: + { + integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== + } + engines: { node: '>=12.20' } hasBin: true snapshots: - tslib@2.3.1: {} typescript@5.0.4: {} diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/not-modified.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/not-modified.yaml index 47ef29262c3..f17060bc2eb 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/not-modified.yaml +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/not-modified.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false importers: - .: {} ../../apps/foo: @@ -19,17 +18,21 @@ importers: version: 5.0.4 packages: - tslib@2.3.1: - resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} + resolution: + { + integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + } typescript@5.0.4: - resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} - engines: {node: '>=12.20'} + resolution: + { + integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== + } + engines: { node: '>=12.20' } hasBin: true snapshots: - tslib@2.3.1: {} typescript@5.0.4: {} diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/overrides-not-modified.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/overrides-not-modified.yaml index d21630b1d81..69e66401e1e 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/overrides-not-modified.yaml +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/overrides-not-modified.yaml @@ -8,7 +8,6 @@ overrides: typescript: 5.0.4 importers: - .: {} ../../apps/foo: @@ -22,17 +21,21 @@ importers: version: 5.0.4 packages: - tslib@2.3.1: - resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} + resolution: + { + integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + } typescript@5.0.4: - resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} - engines: {node: '>=12.20'} + resolution: + { + integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== + } + engines: { node: '>=12.20' } hasBin: true snapshots: - tslib@2.3.1: {} typescript@5.0.4: {} diff --git a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/pnpm-lock-v9.yaml b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/pnpm-lock-v9.yaml index 9fe144e1b91..91130e28c33 100644 --- a/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/pnpm-lock-v9.yaml +++ b/libraries/rush-lib/src/logic/pnpm/test/yamlFiles/pnpm-lock-v9/pnpm-lock-v9.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false importers: - .: dependencies: jquery: @@ -16,20 +15,27 @@ importers: version: 2.1.0 packages: - jquery@3.7.1: - resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} + resolution: + { + integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== + } pad-left@2.1.0: - resolution: {integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==} - engines: {node: '>=0.10.0'} + resolution: + { + integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA== + } + engines: { node: '>=0.10.0' } repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} + resolution: + { + integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + } + engines: { node: '>=0.10' } snapshots: - jquery@3.7.1: {} pad-left@2.1.0: From 2530f74899e9c0a15e1691ec274c1579843642a3 Mon Sep 17 00:00:00 2001 From: Pete Gonzalez <4673363+octogonz@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:16:09 -0700 Subject: [PATCH 22/22] Change `IAsyncParallelismOptions.allowOversubscription` default to false; improve docs --- ...-concurrency-bug-fix_2025-09-11-15-24.json | 2 +- libraries/node-core-library/src/Async.ts | 23 ++++++++++++++----- .../node-core-library/src/test/Async.test.ts | 2 +- .../common/config/rush/command-line.json | 8 +++++++ .../rush-lib/src/cli/RushCommandLineParser.ts | 4 ++++ .../src/schemas/command-line.schema.json | 4 ++-- 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json index 2d0115b64f6..6b24dd9f761 100644 --- a/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json +++ b/common/changes/@rushstack/node-core-library/eb-concurrency-bug-fix_2025-09-11-15-24.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Add an `allowOversubscription` option to the `Async` API functions which prevents running tasks from exceeding concurrency.", + "comment": "Add an `allowOversubscription` option to the `Async` API functions which prevents running tasks from exceeding concurrency. Change its default to `false`.", "type": "minor" } ], diff --git a/libraries/node-core-library/src/Async.ts b/libraries/node-core-library/src/Async.ts index 098c5d3eaa6..9fb6bb9fd08 100644 --- a/libraries/node-core-library/src/Async.ts +++ b/libraries/node-core-library/src/Async.ts @@ -19,15 +19,26 @@ export interface IAsyncParallelismOptions { concurrency?: number; /** - * Optionally used with the {@link (Async:class).(forEachAsync:2)} to enable weighted operations where an operation can - * take up more or less than one concurrency unit. + * Optionally used with the {@link (Async:class).(forEachAsync:2)} to enable weighted operations where an + * operation can take up more or less than one concurrency unit. */ weighted?: boolean; /** - * Controls whether operations can start even if doing so would exceed the total concurrency limit. - * If true (default), will start operations even when they would exceed the limit. - * If false, waits until sufficient capacity is available. + * This option affects the handling of task weights, applying a softer policy that favors maximizing parallelism + * instead of avoiding overload. + * + * @remarks + * By default, a new task cannot start executing if doing so would push the total weight above the concurrency limit. + * Set `allowOversubscription` to true to relax this rule, allowing a new task to start as long as the current + * total weight is below the concurrency limit. Either way, a task cannot start if the total weight already equals + * the concurrency limit; therefore, `allowOversubscription` has no effect when all tasks have weight 1. + * + * Example: Suppose the concurrency limit is 8, and seven tasks are running whose weights are 1, so the current + * total weight is 7. If an available task has weight 2, that would push the total weight to 9, exceeding + * the limit. This task can start only if `allowOversubscription` is true. + * + * @defaultValue false */ allowOversubscription?: boolean; } @@ -238,7 +249,7 @@ export class Async { // Wait until there's enough capacity to run this job, this function will be re-entered as tasks call `onOperationCompletionAsync` const wouldExceedConcurrency: boolean = concurrentUnitsInProgress + weight > concurrency; - const allowOversubscription: boolean = options?.allowOversubscription ?? true; + const allowOversubscription: boolean = options?.allowOversubscription ?? false; if (!allowOversubscription && wouldExceedConcurrency) { // eslint-disable-next-line require-atomic-updates nextIterator = currentIteratorResult; diff --git a/libraries/node-core-library/src/test/Async.test.ts b/libraries/node-core-library/src/test/Async.test.ts index 76e991c6a97..c7c5468bc9d 100644 --- a/libraries/node-core-library/src/test/Async.test.ts +++ b/libraries/node-core-library/src/test/Async.test.ts @@ -487,7 +487,7 @@ describe(Async.name, () => { running--; }); - await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true }); + await Async.forEachAsync(array, fn, { concurrency: 3, weighted: true, allowOversubscription: true }); expect(fn).toHaveBeenCalledTimes(8); expect(maxRunning).toEqual(2); }); diff --git a/libraries/rush-lib/assets/rush-init/common/config/rush/command-line.json b/libraries/rush-lib/assets/rush-init/common/config/rush/command-line.json index 8b972ba7734..3760029c00d 100644 --- a/libraries/rush-lib/assets/rush-init/common/config/rush/command-line.json +++ b/libraries/rush-lib/assets/rush-init/common/config/rush/command-line.json @@ -77,6 +77,14 @@ */ "enableParallelism": false, + /** + * Controls whether weighted operations can start when the total weight would exceed the limit + * but is currently below the limit. This setting only applies when "enableParallelism" is true + * and operations have a "weight" property configured in their rush-project.json "operationSettings". + * Choose true (the default) to favor parallelism. Choose false to strictly stay under the limit. + */ + "allowOversubscription": false, + /** * Normally projects will be processed according to their dependency order: a given project will not start * processing the command until all of its dependencies have completed. This restriction doesn't apply for diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index feb3f052d6f..19e6a095f34 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -470,6 +470,10 @@ export class RushCommandLineParser extends CommandLineParser { enableParallelism: command.enableParallelism, incremental: command.incremental || false, disableBuildCache: command.disableBuildCache || false, + + // The Async.forEachAsync() API defaults allowOversubscription=false, whereas Rush historically + // defaults allowOversubscription=true to favor faster builds rather than strictly staying below + // the CPU limit. allowOversubscription: command.allowOversubscription ?? true, initialPhases: command.phases, diff --git a/libraries/rush-lib/src/schemas/command-line.schema.json b/libraries/rush-lib/src/schemas/command-line.schema.json index a51ec961a65..0091e7bb7ae 100644 --- a/libraries/rush-lib/src/schemas/command-line.schema.json +++ b/libraries/rush-lib/src/schemas/command-line.schema.json @@ -69,7 +69,7 @@ "allowOversubscription": { "title": "allowOversubscription", "type": "boolean", - "description": "Controls whether operations can start even if doing so would exceed the total concurrency limit. This setting only applies when 'enableParallelism' is true and operations have a 'weight' property configured in their rush-project.json operationSettings. If true (default), operations will start even when they would exceed the limit. If false, operations wait until sufficient capacity is available." + "description": "Controls whether weighted operations can start when the total weight would exceed the limit but is currently below the limit. This setting only applies when \"enableParallelism\" is true and operations have a \"weight\" property configured in their rush-project.json \"operationSettings\". Choose true (the default) to favor parallelism. Choose false to strictly stay under the limit." }, "ignoreDependencyOrder": { "title": "ignoreDependencyOrder", @@ -190,7 +190,7 @@ "allowOversubscription": { "title": "allowOversubscription", "type": "boolean", - "description": "Controls whether operations can start even if doing so would exceed the total concurrency limit. This setting only applies when 'enableParallelism' is true and operations have a 'weight' property configured in their rush-project.json operationSettings. If true (default), operations will start even when they would exceed the limit. If false, operations wait until sufficient capacity is available." + "description": "Controls whether weighted operations can start when the total weight would exceed the limit but is currently below the limit. This setting only applies when \"enableParallelism\" is true and operations have a \"weight\" property configured in their rush-project.json \"operationSettings\". Choose true (the default) to favor parallelism. Choose false to strictly stay under the limit." }, "incremental": { "title": "Incremental",