diff --git a/.changeset/smart-shirts-admire.md b/.changeset/smart-shirts-admire.md new file mode 100644 index 000000000..5983e6e0c --- /dev/null +++ b/.changeset/smart-shirts-admire.md @@ -0,0 +1,7 @@ +--- +"@modular-scripts/modular-types": minor +"@modular-scripts/workspace-resolver": minor +--- + +- Add dependency resolver API +- Add lightweight dependency type for users using the API separately diff --git a/packages/modular-types/src/types.ts b/packages/modular-types/src/types.ts index 23a3216c4..7baffb304 100644 --- a/packages/modular-types/src/types.ts +++ b/packages/modular-types/src/types.ts @@ -19,9 +19,12 @@ export type ModularWorkspacePackage = { dependencies: Record | undefined; }; -export interface WorkspaceObj { - location: string; +export interface WorkspaceDependencyObject { workspaceDependencies: string[]; +} + +export interface WorkspaceObj extends WorkspaceDependencyObject { + location: string; mismatchedWorkspaceDependencies: string[]; } diff --git a/packages/workspace-resolver/src/__tests__/resolve-dependencies.test.ts b/packages/workspace-resolver/src/__tests__/resolve-dependencies.test.ts new file mode 100644 index 000000000..bea3538ba --- /dev/null +++ b/packages/workspace-resolver/src/__tests__/resolve-dependencies.test.ts @@ -0,0 +1,392 @@ +import type { + WorkspaceDependencyObject, + WorkspaceMap, +} from '@modular-scripts/modular-types'; + +import { + traverseWorkspaceRelations, + invertDependencyDirection, + computeAncestorSet, + computeDescendantSet, +} from '../resolve-dependencies'; + +describe('resolve-dependencies', () => { + describe('computeDescendantSet', () => { + it('get a descendent set of a number of workspaces', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: [] }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, + }; + expect(computeDescendantSet(['a', 'b'], workspaces)).toEqual( + new Set(['c', 'd']), + ); + expect(computeDescendantSet(['b', 'c'], workspaces)).toEqual( + new Set(['d']), + ); + expect(computeDescendantSet(['d'], workspaces)).toEqual(new Set()); + }); + + it('get a descendent set, ignoring cycles', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['a'] }, + }; + expect(computeDescendantSet(['a', 'b'], workspaces)).toEqual( + new Set(['c']), + ); + expect(computeDescendantSet(['b', 'c'], workspaces)).toEqual( + new Set(['a']), + ); + }); + + it('get a descendent set, ignoring cycles in with different DFS priorities', () => { + const workspaces: Record = { + a: { + workspaceDependencies: ['b'], + }, + b: { + workspaceDependencies: ['c', 'a'], + }, + c: { + workspaceDependencies: [], + }, + }; + expect(computeDescendantSet(['a', 'b'], workspaces)).toEqual( + new Set(['c']), + ); + expect(computeDescendantSet(['b', 'c'], workspaces)).toEqual( + new Set(['a']), + ); + }); + + it('can accept WorkspaceMap', () => { + const workspaces: WorkspaceMap = { + a: { + workspaceDependencies: ['b', 'c'], + location: '/a', + mismatchedWorkspaceDependencies: [], + }, + b: { + workspaceDependencies: ['d'], + location: '/b', + mismatchedWorkspaceDependencies: [], + }, + c: { + workspaceDependencies: ['b'], + location: '/c', + mismatchedWorkspaceDependencies: [], + }, + d: { + workspaceDependencies: [], + location: '/d', + mismatchedWorkspaceDependencies: [], + }, + e: { + workspaceDependencies: ['a', 'b', 'c'], + location: '/e', + mismatchedWorkspaceDependencies: [], + }, + }; + expect(computeDescendantSet(['a', 'b'], workspaces)).toEqual( + new Set(['c', 'd']), + ); + }); + }); + describe('computeAncestorSet', () => { + it('get an ancestors set of a number of workspaces', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: [] }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, + }; + expect(computeAncestorSet(['d', 'b'], workspaces)).toEqual( + new Set(['a', 'c', 'e']), + ); + expect(computeAncestorSet(['a', 'c'], workspaces)).toEqual( + new Set(['e']), + ); + expect(computeAncestorSet(['e'], workspaces)).toEqual(new Set()); + }); + }); + describe('invertDependencyDirection', () => { + it('inverts descendants to ancestors', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: [] }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, + }; + + expect(invertDependencyDirection(workspaces)).toEqual({ + a: { workspaceDependencies: ['e'] }, + b: { workspaceDependencies: ['a', 'c', 'e'] }, + c: { workspaceDependencies: ['a', 'e'] }, + d: { workspaceDependencies: ['b'] }, + }); + }); + }); + describe('walkWorkspaceRelations with a single workspace', () => { + it('resolves descendants in order', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: [] }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, + }; + expect(traverseWorkspaceRelations(['a'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['c', 1], + ['b', 2], + ['d', 3], + ]), + ); + expect(traverseWorkspaceRelations(['b'], workspaces)).toEqual( + new Map([ + ['b', 0], + ['d', 1], + ]), + ); + expect(traverseWorkspaceRelations(['c'], workspaces)).toEqual( + new Map([ + ['c', 0], + ['b', 1], + ['d', 2], + ]), + ); + expect(traverseWorkspaceRelations(['d'], workspaces)).toEqual( + new Map([['d', 0]]), + ); + expect(traverseWorkspaceRelations(['e'], workspaces)).toEqual( + new Map([ + ['e', 0], + ['a', 1], + ['b', 3], + ['c', 2], + ['d', 4], + ]), + ); + }); + + it('resolves a walk with maximum length', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['d'] }, + d: { workspaceDependencies: ['e'] }, + e: { workspaceDependencies: [] }, + }; + + expect(traverseWorkspaceRelations(['a'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ['e', 4], + ]), + ); + }); + + it('resolves descendants in order and recognise tasks with the same order (parallel)', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['d'] }, + d: { workspaceDependencies: [] }, + }; + expect(traverseWorkspaceRelations(['a'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['c', 1], + ['b', 1], + ['d', 2], + ]), + ); + }); + + it('catches a cycle going back to the first dependency', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: [] }, + d: { workspaceDependencies: ['a'] }, + e: { workspaceDependencies: ['d'] }, + }; + expect(() => traverseWorkspaceRelations(['a'], workspaces)).toThrow(); + }); + + it('catches a cycle not going back to the first dependency', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['b'] }, + }; + expect(() => traverseWorkspaceRelations(['a'], workspaces)).toThrow(); + }); + + it('catches a cycle and two dependencies', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['a'] }, + }; + + expect(() => traverseWorkspaceRelations(['a'], workspaces)).toThrow(); + }); + + it('catches a cycle in later descendants', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['b'] }, + }; + expect(() => traverseWorkspaceRelations(['a'], workspaces)).toThrow(); + }); + + it("doesn't throw if a graph recurs back to an older node, but without a cycle", () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'd'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: [] }, + d: { workspaceDependencies: ['b'] }, + }; + expect(() => traverseWorkspaceRelations(['a'], workspaces)).not.toThrow(); + expect(traverseWorkspaceRelations(['a'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['c', 3], + ['b', 2], + ['d', 1], + ]), + ); + }); + + it('Resolves correctly the case where there are no dependencies', () => { + const workspaces: Record = { + a: { workspaceDependencies: [] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: [] }, + d: { workspaceDependencies: ['b'] }, + }; + expect(traverseWorkspaceRelations(['a'], workspaces)).toEqual( + new Map([['a', 0]]), + ); + }); + }); + + describe('walkWorkspaceRelations with more than one workspace', () => { + it('resolves descendants in order', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: [] }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, + }; + expect(traverseWorkspaceRelations(['a', 'b'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['c', 1], + ['b', 2], + ['d', 3], + ]), + ); + expect(traverseWorkspaceRelations(['e', 'a'], workspaces)).toEqual( + new Map([ + ['e', 0], + ['a', 1], + ['b', 3], + ['c', 2], + ['d', 4], + ]), + ); + }); + + it('catches a cycle going back to the first dependency', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: [] }, + d: { workspaceDependencies: ['a'] }, + e: { workspaceDependencies: ['d'] }, + }; + expect(() => + traverseWorkspaceRelations(['a', 'e'], workspaces), + ).toThrow(); + }); + + it('catches a cycle not going back to the first dependency', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['b'] }, + }; + expect(() => + traverseWorkspaceRelations(['a', 'c'], workspaces), + ).toThrow(); + }); + + it('calculates correctly disjointed dependencies', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['d', 'e'] }, + d: { workspaceDependencies: ['e'] }, + e: { workspaceDependencies: [] }, + f: { workspaceDependencies: ['g'] }, + g: { workspaceDependencies: ['h', 'i'] }, + h: { workspaceDependencies: ['i'] }, + i: { workspaceDependencies: [] }, + }; + expect(traverseWorkspaceRelations(['a', 'f'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ['e', 4], + ['f', 0], + ['g', 1], + ['h', 2], + ['i', 3], + ]), + ); + }); + + it('calculates correctly semi-disjointed dependencies', () => { + const workspaces: Record = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['d', 'e'] }, + d: { workspaceDependencies: ['e'] }, + e: { workspaceDependencies: [] }, + f: { workspaceDependencies: ['d', 'g'] }, + g: { workspaceDependencies: ['h', 'i'] }, + h: { workspaceDependencies: ['e', 'i'] }, + i: { workspaceDependencies: [] }, + }; + expect(traverseWorkspaceRelations(['a', 'f'], workspaces)).toEqual( + new Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ['e', 4], + ['f', 0], + ['g', 1], + ['h', 2], + ['i', 3], + ]), + ); + }); + }); +}); diff --git a/packages/workspace-resolver/src/index.ts b/packages/workspace-resolver/src/index.ts index 42ad5e844..cf71ef193 100644 --- a/packages/workspace-resolver/src/index.ts +++ b/packages/workspace-resolver/src/index.ts @@ -2,3 +2,10 @@ export { resolveWorkspace, analyzeWorkspaceDependencies, } from './resolve-workspace'; + +export { + computeAncestorSet, + computeDescendantSet, + traverseWorkspaceRelations, + invertDependencyDirection, +} from './resolve-dependencies'; diff --git a/packages/workspace-resolver/src/resolve-dependencies/README.md b/packages/workspace-resolver/src/resolve-dependencies/README.md new file mode 100644 index 000000000..f641c1b7a --- /dev/null +++ b/packages/workspace-resolver/src/resolve-dependencies/README.md @@ -0,0 +1,378 @@ +# Dependency resolver API + +## computeAncestorSet + +From one or more vertices in a graph, compute the flat set of all their +ancestors (excluding the vertices themselves). This function will work with +cycles, since it doesn't compute the dependency order. + +```ts +computeAncestorSet( + originWorkspaces: string[], + allWorkspaces: Record, +): Set +``` + +### Parameters + +- `originWorkspaces`: the name of the vertices of which we want to know the + ancestors +- `allWorkspaces`: a record of workspaces expressed as + `{ "workspace_name": { workspaceDependencies?: [ 'another_workspace', ...] }, ... }` + +### Example + +```ts +const workspaces = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: undefined }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->B; + E-->A; + E-->B; + E-->C; +``` + +```ts +computeAncestorSet(['d', 'b'], workspaces)) +// Result: new Set(['a', 'c', 'e']) +``` + +## computeDescendantSet + +From one or more vertices in a graph, compute the flat set of all their +dependants (excluding the vertices themselves). This function will work with +cycles, since it doesn't compute the dependency order. + +```ts +computeDescendantSet( + originWorkspaces: string[], + allWorkspaces: Record, +): Set +``` + +### Parameters + +- `originWorkspaces`: the name of the vertices of which we want to know the + ancestors +- `allWorkspaces`: a record of workspaces expressed as + `{ "workspace_name": { workspaceDependencies?: [ 'another_workspace', ...] }, ... }` + +### Example + +```ts +const workspaces = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: undefined }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->B; + E-->A; + E-->B; + E-->C; +``` + +```ts +computeDescendantSet(['a', 'b'], workspaces)) +// Result: new Set(['c', 'd']) +``` + +## traverseWorkspaceRelations + +Traverses a dependency graph from an array of vertices and returns a map from +vertices to inverse build order. The order can be used to compute a set of build +steps, where dependencies of the same order can be executed in parallel. This +function will throw in case of cycles, with an exception describing the first +cycle found, because the order can't be computed (e.g. if `a` needs `b` to be +built and `b` needs `a` to be built, their respective order will grow to +`Infinity`). + +```ts +traverseWorkspaceRelations( + workspaceNames: string[], + workspaces: Record +): OrderedDependencies +``` + +### Parameters + +- `workspaceNames`: an array of start vertices +- `workspaces`: a record of workspaces expressed as + `{ "workspace_name": { workspaceDependencies?: [ 'another_workspace', ...] }, ... }` + +### Examples + +#### Serial order + +```ts +workspaces = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: undefined }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->B; + E-->A; + E-->B; + E-->C; +``` + +```ts +traverseWorkspaceRelations(['a'], workspaces); +/* +// To build a, first build d, then build d, then build c +Map( + [ + ['a', 0], + ['c', 1], + ['b', 2], + ['d', 3], + ] +) +*/ +``` + +#### Serial / parallel order + +```ts +workspaces = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['d'] }, + d: { workspaceDependencies: undefined }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +``` + +```ts +traverseWorkspaceRelations(['a'], workspaces); +/* +// To build a, first build d, then c and b can be built parallely +Map( + [ + ['a', 0], + ['c', 1], + ['b', 1], + ['d', 2], + ] +) +*/ +``` + +#### Cycle + +```ts +workspaces = { + a: { workspaceDependencies: ['b'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['b'] }, +}; +``` + +```mermaid +graph TD; + A-->B; + B-->C; + C-->B; +``` + +```ts +traverseWorkspaceRelations(['a'], workspaces); +/* Will throw */ +``` + +### Multiple vertices with some descendants in common + +```ts +workspaces: = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['d', 'e'] }, + d: { workspaceDependencies: ['e'] }, + e: { workspaceDependencies: [] }, + f: { workspaceDependencies: ['d', 'g'] }, + g: { workspaceDependencies: ['h', 'i'] }, + h: { workspaceDependencies: ['e', 'i'] }, + i: { workspaceDependencies: [] }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->C; + C-->D; + C-->E; + D-->E; + F-->D; + F-->G; + G-->H; + G-->I; + H-->E; + H-->I; +``` + +```ts +/* +Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ['e', 4], + ['f', 0], + ['g', 1], + ['h', 2], + ['i', 3], +]), +*/ +``` + +### Multiple vertices with no descendants in common + +```ts +workspaces = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['c'] }, + c: { workspaceDependencies: ['d', 'e'] }, + d: { workspaceDependencies: ['e'] }, + e: { workspaceDependencies: [] }, + f: { workspaceDependencies: ['g'] }, + g: { workspaceDependencies: ['h', 'i'] }, + h: { workspaceDependencies: ['i'] }, + i: { workspaceDependencies: [] }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->C; + C-->D; + C-->E; + D-->E; + F-->G; + G-->H; + G-->I; + H-->I; +``` + +```ts +traverseWorkspaceRelations(['a', 'f'], workspaces)) + +/* +Lots of parallelism here, since we can build descendants of a at the same time as descendants of f +Map( + [ + ['a', 0], + ['b', 1], + ['c', 2], + ['d', 3], + ['e', 4], + ['f', 0], + ['g', 1], + ['h', 2], + ['i', 3], + ]), +); +*/ +``` + +## invertDependencyDirection + +Takes a dependency graph and inverts the edge direction. The result is a graph +describing the same information as the original but with the relationship +inverted. Useful to produce an ancestor graph from a descendant graph and vice +versa. Please note that, to optimize for performance, this function can produce +graphs in which some `workspaceDependencies` are `undefined` (instead of empty +arrays). + +### Parameters + +- `workspaces`: a record of workspaces expressed as + `{ "workspace_name": { workspaceDependencies?: [ 'another_workspace', ...] }, ... }` + +### Example + +```ts +const workspaces = { + a: { workspaceDependencies: ['b', 'c'] }, + b: { workspaceDependencies: ['d'] }, + c: { workspaceDependencies: ['b'] }, + d: { workspaceDependencies: undefined }, + e: { workspaceDependencies: ['a', 'b', 'c'] }, +}; +``` + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->B; + E-->A; + E-->B; + E-->C; +``` + +```ts +invertDependencyDirection(workspaces); +``` + +#### Resulting (inverted) graph + +```ts +{ + a: { workspaceDependencies: ['e'] }, + b: { workspaceDependencies: ['a', 'c', 'e'] }, + c: { workspaceDependencies: ['a', 'e'] }, + d: { workspaceDependencies: ['b'] }, +} +``` + +```mermaid +graph TD; + A-->E; + B-->A; + B-->C; + B-->E; + C-->A; + C-->E; + D-->B; +``` diff --git a/packages/workspace-resolver/src/resolve-dependencies/index.ts b/packages/workspace-resolver/src/resolve-dependencies/index.ts new file mode 100644 index 000000000..6017fa52b --- /dev/null +++ b/packages/workspace-resolver/src/resolve-dependencies/index.ts @@ -0,0 +1,159 @@ +import type { WorkspaceDependencyObject } from '@modular-scripts/modular-types'; + +type OrderedDependencies = Map; +type OrderedUnvisited = { name: string; level: number }; +type OptionalWorkspaceDependencyObject = Partial; + +export function computeDescendantSet( + workspaceNames: string[], + allWorkspaces: Record, +): Set { + // Really simple and performant ancestors collection: walk the graph via BFS and collect all the dependencies encountered in a set. + // If one dependency was already encountered before, don't process it. This is cycle resistant. + const unvisited: string[] = [...workspaceNames]; + const visited: Set = new Set(); + + while (unvisited.length) { + const currentDependency = unvisited.shift(); + if (!currentDependency) break; + visited.add(currentDependency); + + const immediateDependencies = + allWorkspaces[currentDependency]?.workspaceDependencies; + + if (immediateDependencies) { + for (const immediateDep of immediateDependencies) { + if (!visited.has(immediateDep)) { + unvisited.push(immediateDep); + } + } + } + } + return setDiff(visited, new Set(workspaceNames)); +} + +export function computeAncestorSet( + workspaceNames: string[], + allWorkspaces: Record, +): Set { + // Computing an ancestor set is like computing a dependant set with an inverted graph + return computeDescendantSet( + workspaceNames, + invertDependencyDirection(allWorkspaces), + ); +} + +// This function takes a tree of dependencies (dependant -> child dependencies) +// and returns an equivalent tree where the relation's direction is inverted +// (dependency -> parent dependencies) +// This allows us to use the same algorithm to query ancestors or descendants. +export function invertDependencyDirection( + workspaces: Record, +): Record { + return Object.entries(workspaces).reduce< + Record + >((output, [currentWorkspace, workspaceRecord]) => { + // Loop through all the dependencies for currentWorkspace and save the inverse relation in the output + workspaceRecord.workspaceDependencies?.forEach((dependency) => { + // Create a workspaceAncestors record if not already present + if (!output[dependency]) { + output[dependency] = { workspaceDependencies: [] }; + } + // Insert if the ancestor is not already present. + // This would be less costly with a Set, but a Set would come at the cost of arrayfy-ing all the Sets later + if ( + !output[dependency].workspaceDependencies?.includes(currentWorkspace) + ) { + output[dependency].workspaceDependencies?.push(currentWorkspace); + } + }); + return output; + }, Object.create(null)); +} + +// This function traverses the graph to get an ordered set of dependencies (map reverseOrder => dependencyName) +export function traverseWorkspaceRelations( + workspaceNames: string[], + workspaces: Record, +): OrderedDependencies { + const workspaceN = Object.values(workspaces).length; + // Initialize the unvisited list with the immediate dependency arrays. + const unvisitedList = workspaceNames.reduce( + (acc, workspaceName) => { + const immediate = workspaces[workspaceName]?.workspaceDependencies ?? []; + return [...acc, ...immediate]; + }, + [], + ); + // Dedupe initial unvisited and start from order 1 + const unvisited: OrderedUnvisited[] = Array.from(new Set(unvisitedList)).map( + (dep) => ({ + name: dep, + level: 1, + }), + ); + + // visited holds all the nodes that we've visited previously + const visited: OrderedDependencies = new Map( + workspaceNames.map((dep) => [dep, 0]), + ); + // cycleBreaker holds our DFS path and helps identifying cycles + const cycleBreaker: Set = new Set(); + + while (unvisited.length) { + // Consume the remaining unvisited descendants one by one + const unvisitedDependency = unvisited.shift(); + if (!unvisitedDependency) break; + + const { name: currentDependencyName, level: currentDependencyDepth } = + unvisitedDependency; + cycleBreaker.add(currentDependencyName); + + // Get the next immediate dependencies of the dependency we're visiting. + const immediateDependencies = + workspaces[currentDependencyName]?.workspaceDependencies; + + // Add current dependency to the visited set. + // If we already visited it at a lower depth in the graph, raise its level to the current depth + // (i.e. this dependency could be a dependency of some other node, but since is also a dependency of *this* node, it gets the bigger depth of the two) + const dependencyLevel = visited.has(currentDependencyName) + ? Math.max( + currentDependencyDepth, + visited.get(currentDependencyName) ?? -1, + ) + : currentDependencyDepth; + visited.set(currentDependencyName, dependencyLevel); + + // All our immediate dependencies are inserted into unvisited, with a depth level = this node + 1 + if (immediateDependencies) { + const immediateDependenciesWithDepth = immediateDependencies?.map( + (dep) => ({ + name: dep, + level: currentDependencyDepth + 1, + }), + ); + for (const dep of immediateDependenciesWithDepth) { + // If we're enqueueing a dependency that we have already processed in this walk, we have a cycle. + if (cycleBreaker.has(dep.name) || currentDependencyDepth > workspaceN) { + throw new Error( + `Cycle detected, ${[...cycleBreaker, dep.name].join(' -> ')}`, + ); + } + // If we insert the immediate dependencies at the end (push), we do a BFS walk. + // If we insert them at the start (unshift), we do a DFS walk. + unvisited.unshift(dep); + } + + // If we got to an end node, we finish the current DFS traversal: reset the cycle breaker + if (!immediateDependencies || !immediateDependencies.length) { + cycleBreaker.clear(); + } + } + } + + return visited; +} + +function setDiff(a: Set, b: Set): Set { + return new Set([...a].filter((x) => !b.has(x))); +}