@@ -55,6 +55,129 @@ class PackageGraphNode {
55
55
satisfies ( { gitCommittish, gitRange, fetchSpec } ) {
56
56
return semver . satisfies ( this . version , gitCommittish || gitRange || fetchSpec ) ;
57
57
}
58
+
59
+ /**
60
+ * Returns a string representation of this node (its name)
61
+ *
62
+ * @returns {String }
63
+ */
64
+ toString ( ) {
65
+ return this . name ;
66
+ }
67
+ }
68
+
69
+ let lastCollapsedNodeId = 0 ;
70
+
71
+ /**
72
+ * Represents a cyclic collection of nodes in a PackageGraph.
73
+ * It is meant to be used as a black box, where the only exposed
74
+ * information are the connections to the other nodes of the graph.
75
+ * It can contains either `PackageGraphNode`s or other `CyclicPackageGraphNode`s.
76
+ */
77
+ class CyclicPackageGraphNode extends Map {
78
+ constructor ( ) {
79
+ super ( ) ;
80
+
81
+ this . localDependencies = new Map ( ) ;
82
+ this . localDependents = new Map ( ) ;
83
+
84
+ Object . defineProperties ( this , {
85
+ // immutable properties
86
+ name : {
87
+ enumerable : true ,
88
+ value : `(cycle) ${ ( lastCollapsedNodeId += 1 ) } ` ,
89
+ } ,
90
+ isCycle : {
91
+ value : true ,
92
+ } ,
93
+ } ) ;
94
+ }
95
+
96
+ /**
97
+ * @returns {String } Returns a representation of a cycle, like like `A -> B -> C -> A`.
98
+ */
99
+ toString ( ) {
100
+ const parts = Array . from ( this , ( [ key , node ] ) =>
101
+ node . isCycle ? `(nested cycle: ${ node . toString ( ) } )` : key
102
+ ) ;
103
+
104
+ // start from the origin
105
+ parts . push ( parts [ 0 ] ) ;
106
+
107
+ return parts . reverse ( ) . join ( " -> " ) ;
108
+ }
109
+
110
+ /**
111
+ * Flattens a CyclicPackageGraphNode (which can have multiple level of cycles).
112
+ *
113
+ * @returns {PackageGraphNode[] }
114
+ */
115
+ flatten ( ) {
116
+ const result = [ ] ;
117
+
118
+ for ( const node of this . values ( ) ) {
119
+ if ( node . isCycle ) {
120
+ result . push ( ...node . flatten ( ) ) ;
121
+ } else {
122
+ result . push ( node ) ;
123
+ }
124
+ }
125
+
126
+ return result ;
127
+ }
128
+
129
+ /**
130
+ * Checks if a given node is contained in this cycle (or in a nested one)
131
+ *
132
+ * @param {String } name The name of the package to search in this cycle
133
+ * @returns {Boolean }
134
+ */
135
+ contains ( name ) {
136
+ for ( const [ currentName , currentNode ] of this ) {
137
+ if ( currentNode . isCycle ) {
138
+ if ( currentNode . contains ( name ) ) {
139
+ return true ;
140
+ }
141
+ } else if ( currentName === name ) {
142
+ return true ;
143
+ }
144
+ }
145
+ return false ;
146
+ }
147
+
148
+ /**
149
+ * Adds a graph node, or a nested cycle, to this group.
150
+ *
151
+ * @param {PackageGraphNode|CyclicPackageGraphNode } node
152
+ */
153
+ insert ( node ) {
154
+ this . set ( node . name , node ) ;
155
+ this . unlink ( node ) ;
156
+
157
+ for ( const [ dependencyName , dependencyNode ] of node . localDependencies ) {
158
+ if ( ! this . contains ( dependencyName ) ) {
159
+ this . localDependencies . set ( dependencyName , dependencyNode ) ;
160
+ }
161
+ }
162
+
163
+ for ( const [ dependentName , dependentNode ] of node . localDependents ) {
164
+ if ( ! this . contains ( dependentName ) ) {
165
+ this . localDependents . set ( dependentName , dependentNode ) ;
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Remove pointers to candidate node from internal collections.
172
+ * @param {PackageGraphNode|CyclicPackageGraphNode } candidateNode instance to unlink
173
+ */
174
+ unlink ( candidateNode ) {
175
+ // remove incoming edges ("indegree")
176
+ this . localDependencies . delete ( candidateNode . name ) ;
177
+
178
+ // remove outgoing edges ("outdegree")
179
+ this . localDependents . delete ( candidateNode . name ) ;
180
+ }
58
181
}
59
182
60
183
/**
@@ -190,7 +313,10 @@ class PackageGraph extends Map {
190
313
}
191
314
192
315
/**
193
- * Return a tuple of cycle paths and nodes, which have been removed from the graph.
316
+ * Return a tuple of cycle paths and nodes.
317
+ *
318
+ * @deprecated Use collapseCycles instead.
319
+ *
194
320
* @param {!boolean } rejectCycles Whether or not to reject cycles
195
321
* @returns [Set<String[]>, Set<PackageGraphNode>]
196
322
*/
@@ -238,19 +364,71 @@ class PackageGraph extends Map {
238
364
currentNode . localDependents . forEach ( visits ( [ currentName ] ) ) ;
239
365
} ) ;
240
366
241
- if ( cyclePaths . size ) {
242
- const cycleMessage = [ "Dependency cycles detected, you should fix these!" ]
243
- . concat ( Array . from ( cyclePaths ) . map ( cycle => cycle . join ( " -> " ) ) )
244
- . join ( "\n" ) ;
367
+ reportCycles ( Array . from ( cyclePaths , cycle => cycle . join ( " -> " ) ) , rejectCycles ) ;
245
368
246
- if ( rejectCycles ) {
247
- throw new ValidationError ( "ECYCLE" , cycleMessage ) ;
369
+ return [ cyclePaths , cycleNodes ] ;
370
+ }
371
+
372
+ /**
373
+ * Returns the cycles of this graph. If two cycles share some elements, they will
374
+ * be returned as a single cycle.
375
+ *
376
+ * @param {!boolean } rejectCycles Whether or not to reject cycles
377
+ * @returns Set<CyclicPackageGraphNode>
378
+ */
379
+ collapseCycles ( rejectCycles ) {
380
+ const cyclePaths = [ ] ;
381
+ const nodeToCycle = new Map ( ) ;
382
+ const cycles = new Set ( ) ;
383
+
384
+ const walkStack = [ ] ;
385
+
386
+ function visits ( baseNode , dependentNode ) {
387
+ if ( nodeToCycle . has ( baseNode ) ) {
388
+ return ;
389
+ }
390
+
391
+ let topLevelDependent = dependentNode ;
392
+ while ( nodeToCycle . has ( topLevelDependent ) ) {
393
+ topLevelDependent = nodeToCycle . get ( topLevelDependent ) ;
394
+ }
395
+
396
+ if (
397
+ topLevelDependent === baseNode ||
398
+ ( topLevelDependent . isCycle && topLevelDependent . has ( baseNode . name ) )
399
+ ) {
400
+ const cycle = new CyclicPackageGraphNode ( ) ;
401
+
402
+ walkStack . forEach ( nodeInCycle => {
403
+ nodeToCycle . set ( nodeInCycle , cycle ) ;
404
+ cycle . insert ( nodeInCycle ) ;
405
+ cycles . delete ( nodeInCycle ) ;
406
+ } ) ;
407
+
408
+ cycles . add ( cycle ) ;
409
+ cyclePaths . push ( cycle . toString ( ) ) ;
410
+
411
+ return ;
248
412
}
249
413
250
- log . warn ( "ECYCLE" , cycleMessage ) ;
414
+ if ( walkStack . indexOf ( topLevelDependent ) === - 1 ) {
415
+ // eslint-disable-next-line no-use-before-define
416
+ visitWithStack ( baseNode , topLevelDependent ) ;
417
+ }
251
418
}
252
419
253
- return [ cyclePaths , cycleNodes ] ;
420
+ function visitWithStack ( baseNode , currentNode = baseNode ) {
421
+ walkStack . push ( currentNode ) ;
422
+ currentNode . localDependents . forEach ( visits . bind ( null , baseNode ) ) ;
423
+ walkStack . pop ( ) ;
424
+ }
425
+
426
+ this . forEach ( currentNode => visitWithStack ( currentNode ) ) ;
427
+ cycles . forEach ( collapsedNode => visitWithStack ( collapsedNode ) ) ;
428
+
429
+ reportCycles ( cyclePaths , rejectCycles ) ;
430
+
431
+ return cycles ;
254
432
}
255
433
256
434
/**
@@ -291,4 +469,18 @@ class PackageGraph extends Map {
291
469
}
292
470
}
293
471
472
+ function reportCycles ( paths , rejectCycles ) {
473
+ if ( ! paths . length ) {
474
+ return ;
475
+ }
476
+
477
+ const cycleMessage = [ "Dependency cycles detected, you should fix these!" ] . concat ( paths ) . join ( "\n" ) ;
478
+
479
+ if ( rejectCycles ) {
480
+ throw new ValidationError ( "ECYCLE" , cycleMessage ) ;
481
+ }
482
+
483
+ log . warn ( "ECYCLE" , cycleMessage ) ;
484
+ }
485
+
294
486
module . exports = PackageGraph ;
0 commit comments