Skip to content

Commit 5705237

Browse files
committed
feat: break cyclic graphs when converting into tree
This pr will add support for converting a cyclic graph into a tree Image example: convert `cyclic-complex-dep-graph.png` into `cyclic-complex-dep-graph-expected-optimized-tree.png`
1 parent 1292331 commit 5705237

8 files changed

+373
-21
lines changed

src/legacy/cycles.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
type NodeId = string;
2+
type Cycle = NodeId[];
3+
4+
export type Cycles = Cycle[];
5+
6+
export type PartitionedCycles = {
7+
cyclesStartWithThisNode: Cycle[];
8+
cyclesWithThisNode: Cycle[];
9+
};
10+
11+
export function getCycle(ancestors: NodeId[], nodeId: NodeId): Cycle | null {
12+
if (!ancestors.includes(nodeId)) {
13+
return null;
14+
}
15+
16+
// first item is where the cycle starts and ends.
17+
return ancestors.slice(ancestors.indexOf(nodeId));
18+
}
19+
20+
export function partitionCycles(
21+
nodeId: NodeId,
22+
allCyclesTheNodeIsPartOf: Cycle[],
23+
): PartitionedCycles {
24+
const cyclesStartWithThisNode: Cycle[] = [];
25+
const cyclesWithThisNode: Cycle[] = [];
26+
27+
for (const cycle of allCyclesTheNodeIsPartOf) {
28+
const nodeStartsCycle = cycle[0] === nodeId;
29+
if (nodeStartsCycle) {
30+
cyclesStartWithThisNode.push(cycle);
31+
} else {
32+
cyclesWithThisNode.push(cycle);
33+
}
34+
}
35+
return { cyclesStartWithThisNode, cyclesWithThisNode };
36+
}

src/legacy/index.ts

+38-16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { eventLoopSpinner } from 'event-loop-spinner';
44
import * as types from '../core/types';
55
import { DepGraphBuilder } from '../core/builder';
66
import objectHash = require('object-hash');
7+
import { getCycle, partitionCycles, Cycles } from './cycles';
8+
import { getMemoizedDepTree, memoize, MemoizationMap } from './memiozation';
79

810
export { depTreeToGraph, graphToDepTree, DepTree };
911

@@ -230,12 +232,7 @@ async function graphToDepTree(
230232
): Promise<DepTree> {
231233
const depGraph = depGraphInterface as types.DepGraphInternal;
232234

233-
// TODO: implement cycles support
234-
if (depGraph.hasCycles()) {
235-
throw new Error('Conversion to DepTree does not support cyclic graphs yet');
236-
}
237-
238-
const depTree = await buildSubtree(
235+
const [depTree] = await buildSubtree(
239236
depGraph,
240237
depGraph.rootNodeId,
241238
opts.deduplicateWithinTopLevelDeps ? null : false,
@@ -286,10 +283,18 @@ async function buildSubtree(
286283
depGraph: types.DepGraphInternal,
287284
nodeId: string,
288285
maybeDeduplicationSet: Set<string> | false | null = false, // false = disabled; null = not in deduplication scope yet
289-
memoizationMap: Map<string, DepTree> = new Map(),
290-
): Promise<DepTree> {
291-
if (!maybeDeduplicationSet && memoizationMap.has(nodeId)) {
292-
return memoizationMap.get(nodeId)!;
286+
ancestors: string[] = [],
287+
memoizationMap: MemoizationMap = new Map(),
288+
): Promise<[DepTree, Cycles | undefined]> {
289+
if (!maybeDeduplicationSet) {
290+
const memoizedDepTree = getMemoizedDepTree(
291+
nodeId,
292+
ancestors,
293+
memoizationMap,
294+
);
295+
if (memoizedDepTree) {
296+
return [memoizedDepTree, undefined];
297+
}
293298
}
294299
const isRoot = nodeId === depGraph.rootNodeId;
295300
const nodePkg = depGraph.getNodePkg(nodeId);
@@ -306,32 +311,46 @@ async function buildSubtree(
306311

307312
const depInstanceIds = depGraph.getNodeDepsNodeIds(nodeId);
308313
if (!depInstanceIds || depInstanceIds.length === 0) {
309-
memoizationMap.set(nodeId, depTree);
310-
return depTree;
314+
memoizationMap.set(nodeId, { depTree });
315+
return [depTree, undefined];
316+
}
317+
318+
const cycle = getCycle(ancestors, nodeId);
319+
if (cycle) {
320+
// This node starts a cycle and now it's the second visit.
321+
addLabel(depTree, 'pruned', 'cyclic');
322+
return [depTree, [cycle]];
311323
}
312324

313325
if (maybeDeduplicationSet) {
314326
if (maybeDeduplicationSet.has(nodeId)) {
315327
if (depInstanceIds.length > 0) {
316328
addLabel(depTree, 'pruned', 'true');
317329
}
318-
return depTree;
330+
return [depTree, undefined];
319331
}
320332
maybeDeduplicationSet.add(nodeId);
321333
}
322334

335+
const cycles: Cycles = [];
323336
for (const depInstId of depInstanceIds) {
324337
// Deduplication of nodes occurs only within a scope of a top-level dependency.
325338
// Therefore, every top-level dep gets an independent set to track duplicates.
326339
if (isRoot && maybeDeduplicationSet !== false) {
327340
maybeDeduplicationSet = new Set();
328341
}
329-
const subtree = await buildSubtree(
342+
const [subtree, subtreeCycles] = await buildSubtree(
330343
depGraph,
331344
depInstId,
332345
maybeDeduplicationSet,
346+
ancestors.concat(nodeId),
333347
memoizationMap,
334348
);
349+
if (subtreeCycles) {
350+
for (const cycle of subtreeCycles) {
351+
cycles.push(cycle);
352+
}
353+
}
335354
if (!subtree) {
336355
continue;
337356
}
@@ -346,8 +365,11 @@ async function buildSubtree(
346365
if (eventLoopSpinner.isStarving()) {
347366
await eventLoopSpinner.spin();
348367
}
349-
memoizationMap.set(nodeId, depTree);
350-
return depTree;
368+
369+
const partitionedCycles = partitionCycles(nodeId, cycles);
370+
memoize(nodeId, memoizationMap, depTree, partitionedCycles);
371+
372+
return [depTree, partitionedCycles.cyclesWithThisNode];
351373
}
352374

353375
function trimAfterLastSep(str: string, sep: string) {

src/legacy/memiozation.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { DepTree } from './index';
2+
import { PartitionedCycles } from './cycles';
3+
4+
type NodeId = string;
5+
6+
export type MemoizationMap = Map<
7+
NodeId,
8+
{
9+
depTree: DepTree;
10+
11+
// the cycleNodeIds holds the nodes ids in a cycle
12+
// i.e. for the cyclic graph "1->2->3->4->2", for nodeId=2 the cycleNodeIds will be "3,4"
13+
// if nodeId exists in cycleNodeIds, don't use memoized depTree version
14+
cycleNodeIds?: Set<NodeId>;
15+
}
16+
>;
17+
18+
export function memoize(
19+
nodeId: NodeId,
20+
memoizationMap: MemoizationMap,
21+
depTree: DepTree,
22+
partitionedCycles: PartitionedCycles,
23+
) {
24+
const { cyclesStartWithThisNode, cyclesWithThisNode } = partitionedCycles;
25+
if (cyclesStartWithThisNode.length > 0) {
26+
const cycleNodeIds = new Set<NodeId>(...cyclesStartWithThisNode);
27+
memoizationMap.set(nodeId, { depTree, cycleNodeIds });
28+
} else if (cyclesWithThisNode.length === 0) {
29+
memoizationMap.set(nodeId, { depTree });
30+
}
31+
// Don't memoize nodes in cycles (cyclesWithThisNode.length > 0)
32+
}
33+
34+
export function getMemoizedDepTree(
35+
nodeId: NodeId,
36+
ancestors: NodeId[],
37+
memoizationMap: MemoizationMap,
38+
): DepTree | null {
39+
if (!memoizationMap.has(nodeId)) return null;
40+
41+
const { depTree, cycleNodeIds } = memoizationMap.get(nodeId)!;
42+
if (!cycleNodeIds) return depTree;
43+
44+
const ancestorsArePartOfTheCycle = ancestors.some((nodeId) =>
45+
cycleNodeIds.has(nodeId),
46+
);
47+
48+
return ancestorsArePartOfTheCycle ? null : depTree;
49+
}
Loading
+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
{
2+
"// graph image": "cyclic-complex-dep-graph.png",
3+
"// expected dep-tree image": "cyclic-complex-dep-graph-expected-optimized-tree.png",
4+
"schemaVersion": "1.2.0",
5+
"pkgManager": {
6+
"name": "pip"
7+
},
8+
"pkgs": [
9+
{
10+
"id": "a@1",
11+
"info": {
12+
"name": "a",
13+
"version": "1"
14+
}
15+
},
16+
{
17+
"id": "b@2",
18+
"info": {
19+
"name": "b",
20+
"version": "2"
21+
}
22+
},
23+
{
24+
"id": "c@3",
25+
"info": {
26+
"name": "c",
27+
"version": "3"
28+
}
29+
},
30+
{
31+
"id": "d@4",
32+
"info": {
33+
"name": "d",
34+
"version": "4"
35+
}
36+
},
37+
{
38+
"id": "e@5",
39+
"info": {
40+
"name": "e",
41+
"version": "5"
42+
}
43+
},
44+
{
45+
"id": "f@6",
46+
"info": {
47+
"name": "f",
48+
"version": "6"
49+
}
50+
},
51+
{
52+
"id": "g@7",
53+
"info": {
54+
"name": "g",
55+
"version": "7"
56+
}
57+
}
58+
],
59+
"graph": {
60+
"rootNodeId": "root-node",
61+
"nodes": [
62+
{
63+
"nodeId": "root-node",
64+
"pkgId": "a@1",
65+
"deps": [
66+
{
67+
"nodeId": "2"
68+
},
69+
{
70+
"nodeId": "3"
71+
},
72+
{
73+
"nodeId": "4"
74+
}
75+
]
76+
},
77+
{
78+
"nodeId": "2",
79+
"pkgId": "b@2",
80+
"deps": [
81+
{
82+
"nodeId": "5"
83+
}
84+
]
85+
},
86+
{
87+
"nodeId": "3",
88+
"pkgId": "c@3",
89+
"deps": [
90+
{
91+
"nodeId": "5"
92+
}
93+
]
94+
},
95+
{
96+
"nodeId": "4",
97+
"pkgId": "d@4",
98+
"deps": [
99+
{
100+
"nodeId": "6"
101+
}
102+
]
103+
},
104+
{
105+
"nodeId": "5",
106+
"pkgId": "e@5",
107+
"deps": [
108+
{
109+
"nodeId": "6"
110+
}
111+
]
112+
},
113+
{
114+
"nodeId": "6",
115+
"pkgId": "f@6",
116+
"deps": [
117+
{
118+
"nodeId": "7"
119+
}
120+
]
121+
},
122+
{
123+
"nodeId": "7",
124+
"pkgId": "g@7",
125+
"deps": [
126+
{
127+
"nodeId": "5"
128+
}
129+
]
130+
}
131+
]
132+
}
133+
}
118 KB
Loading

0 commit comments

Comments
 (0)