codekingpro/portable-devtools
114k
1// An edge in the dependency graph
2// Represents a dependency relationship of some kind
3
4const util = require('node:util')
5const npa = require('npm-package-arg')
6const depValid = require('./dep-valid.js')
7const OverrideSet = require('./override-set.js')
8
9class ArboristEdge {
10 constructor (edge) {
11 this.name = edge.name
12 this.spec = edge.spec
13 this.type = edge.type
14
15 const edgeFrom = edge.from?.location
16 const edgeTo = edge.to?.location
17 const override = edge.overrides?.value
18
19 if (edgeFrom != null) {
20 this.from = edgeFrom
21 }
22 if (edgeTo) {
23 this.to = edgeTo
24 }
25 if (edge.error) {
26 this.error = edge.error
27 }
28 if (edge.peerConflicted) {
29 this.peerConflicted = true
30 }
31 if (override) {
32 this.overridden = override
33 }
34 }
35}
36
37class Edge {
38 #accept
39 #error
40 #explanation
41 #from
42 #name
43 #spec
44 #to
45 #type
46
47 static types = Object.freeze([
48 'prod',
49 'dev',
50 'optional',
51 'peer',
52 'peerOptional',
53 'workspace',
54 ])
55
56 // XXX where is this used?
57 static errors = Object.freeze([
58 'DETACHED',
59 'MISSING',
60 'PEER LOCAL',
61 'INVALID',
62 ])
63
64 constructor (options) {
65 const { type, name, spec, accept, from, overrides } = options
66
67 // XXX are all of these error states even possible?
68 if (typeof spec !== 'string') {
69 throw new TypeError('must provide string spec')
70 }
71 if (!Edge.types.includes(type)) {
72 throw new TypeError(`invalid type: ${type}\n(valid types are: ${Edge.types.join(', ')})`)
73 }
74 if (type === 'workspace' && npa(spec).type !== 'directory') {
75 throw new TypeError('workspace edges must be a symlink')
76 }
77 if (typeof name !== 'string') {
78 throw new TypeError('must provide dependency name')
79 }
80 if (!from) {
81 throw new TypeError('must provide "from" node')
82 }
83 if (accept !== undefined) {
84 if (typeof accept !== 'string') {
85 throw new TypeError('accept field must be a string if provided')
86 }
87 this.#accept = accept || '*'
88 }
89 if (overrides !== undefined) {
90 this.overrides = overrides
91 }
92
93 this.#name = name
94 this.#type = type
95 this.#spec = spec
96 this.#explanation = null
97 this.#from = from
98
99 from.edgesOut.get(this.#name)?.detach()
100 from.addEdgeOut(this)
101
102 this.reload(true)
103 this.peerConflicted = false
104 }
105
106 satisfiedBy (node) {
107 if (node.name !== this.#name || !this.#from) {
108 return false
109 }
110
111 // NOTE: this condition means we explicitly do not support overriding
112 // bundled or shrinkwrapped dependencies
113 if (node.hasShrinkwrap || node.inShrinkwrap || node.inBundle) {
114 return depValid(node, this.rawSpec, this.#accept, this.#from)
115 }
116
117 // If there's no override we just use the spec.
118 if (!this.overrides?.keySpec) {
119 return depValid(node, this.spec, this.#accept, this.#from)
120 }
121 // There's some override. If the target node satisfies the overriding spec
122 // then it's okay.
123 if (depValid(node, this.spec, this.#accept, this.#from)) {
124 return true
125 }
126 // If it doesn't, then it should at least satisfy the original spec.
127 if (!depValid(node, this.rawSpec, this.#accept, this.#from)) {
128 return false
129 }
130 // It satisfies the original spec, not the overriding spec. We need to make
131 // sure it doesn't use the overridden spec.
132 // For example:
133 // we might have an ^8.0.0 rawSpec, and an override that makes
134 // keySpec=8.23.0 and the override value spec=9.0.0.
135 // If the node is 9.0.0, then it's okay because it's consistent with spec.
136 // If the node is 8.24.0, then it's okay because it's consistent with the rawSpec.
137 // If the node is 8.23.0, then it's not okay because even though it's consistent
138 // with the rawSpec, it's also consistent with the keySpec.
139 // So we're looking for ^8.0.0 or 9.0.0 and not 8.23.0.
140 return !depValid(node, this.overrides.keySpec, this.#accept, this.#from)
141 }
142
143 // return the edge data, and an explanation of how that edge came to be here
144 explain (seen = []) {
145 if (!this.#explanation) {
146 const explanation = {
147 type: this.#type,
148 name: this.#name,
149 spec: this.spec,
150 }
151 if (this.rawSpec !== this.spec) {
152 explanation.rawSpec = this.rawSpec
153 explanation.overridden = true
154 }
155 if (this.bundled) {
156 explanation.bundled = this.bundled
157 }
158 if (this.error) {
159 explanation.error = this.error
160 }
161 if (this.#from) {
162 explanation.from = this.#from.explain(null, seen)
163 }
164 this.#explanation = explanation
165 }
166 return this.#explanation
167 }
168
169 get bundled () {
170 return !!this.#from?.package?.bundleDependencies?.includes(this.#name)
171 }
172
173 get workspace () {
174 return this.#type === 'workspace'
175 }
176
177 get prod () {
178 return this.#type === 'prod'
179 }
180
181 get dev () {
182 return this.#type === 'dev'
183 }
184
185 get optional () {
186 return this.#type === 'optional' || this.#type === 'peerOptional'
187 }
188
189 get peer () {
190 return this.#type === 'peer' || this.#type === 'peerOptional'
191 }
192
193 get type () {
194 return this.#type
195 }
196
197 get name () {
198 return this.#name
199 }
200
201 get rawSpec () {
202 return this.#spec
203 }
204
205 get spec () {
206 if (this.overrides?.value && this.overrides.value !== '*' && this.overrides.name === this.#name) {
207 if (this.overrides.value.startsWith('$')) {
208 const ref = this.overrides.value.slice(1)
209 let pkg = this.#from?.sourceReference
210 ? this.#from?.sourceReference.root.package
211 : this.#from?.root?.package
212
213 let specValue = this.#calculateReferentialOverrideSpec(ref, pkg)
214
215 // If the package isn't found in the root package, fall back to the local package.
216 if (!specValue) {
217 pkg = this.#from?.package
218 specValue = this.#calculateReferentialOverrideSpec(ref, pkg)
219 }
220
221 if (specValue) {
222 return specValue
223 }
224 throw new Error(`Unable to resolve reference ${this.overrides.value}`)
225 }
226 return this.overrides.value
227 }
228 return this.#spec
229 }
230
231 #calculateReferentialOverrideSpec (ref, pkg) {
232 if (pkg.devDependencies?.[ref]) {
233 return pkg.devDependencies[ref]
234 }
235 if (pkg.optionalDependencies?.[ref]) {
236 return pkg.optionalDependencies[ref]
237 }
238 if (pkg.dependencies?.[ref]) {
239 return pkg.dependencies[ref]
240 }
241 if (pkg.peerDependencies?.[ref]) {
242 return pkg.peerDependencies[ref]
243 }
244 }
245
246 get accept () {
247 return this.#accept
248 }
249
250 get valid () {
251 return !this.error
252 }
253
254 get missing () {
255 return this.error === 'MISSING'
256 }
257
258 get invalid () {
259 return this.error === 'INVALID'
260 }
261
262 get peerLocal () {
263 return this.error === 'PEER LOCAL'
264 }
265
266 get error () {
267 if (!this.#error) {
268 if (!this.#to) {
269 if (this.optional) {
270 this.#error = null
271 } else {
272 this.#error = 'MISSING'
273 }
274 } else if (this.peer && this.#from === this.#to.parent && !this.#from?.isTop) {
275 this.#error = 'PEER LOCAL'
276 } else if (!this.satisfiedBy(this.#to)) {
277 this.#error = 'INVALID'
278 } else if (this.overrides && this.#to.overrides && this.#to.edgesOut.size && OverrideSet.doOverrideSetsConflict(this.overrides, this.#to.overrides)) {
279 // Check for conflicts between the edge's override set and the target node's override set.
280 // This catches cases where different parts of the tree have genuinely incompatible
281 // version requirements for the same package.
282 // The improved conflict detection uses semantic comparison (checking for incompatible
283 // version ranges) rather than pure structural equality, avoiding false positives from:
284 // - Reference overrides ($syntax) that resolve to compatible versions
285 // - Peer dependencies with different but compatible override contexts
286 // Note: We only check if the target has dependencies (edgesOut.size > 0), since
287 // override conflicts are only relevant if the target has its own dependencies.
288 this.#error = 'INVALID'
289 } else {
290 this.#error = 'OK'
291 }
292 }
293 if (this.#error === 'OK') {
294 return null
295 }
296 return this.#error
297 }
298
299 reload (hard = false) {
300 this.#explanation = null
301
302 let needToUpdateOverrideSet = false
303 let newOverrideSet
304 let oldOverrideSet
305 if (this.#from?.overrides) {
306 newOverrideSet = this.#from.overrides.getEdgeRule(this)
307 if (newOverrideSet && !newOverrideSet.isEqual(this.overrides)) {
308 // If there's a new different override set we need to propagate it to the nodes.
309 // If we're deleting the override set then there's no point propagating it right now since it will be filled with another value later.
310 needToUpdateOverrideSet = true
311 oldOverrideSet = this.overrides
312 this.overrides = newOverrideSet
313 }
314 } else {
315 delete this.overrides
316 }
317 const newTo = this.#from?.resolve(this.#name)
318 if (newTo !== this.#to) {
319 if (this.#to) {
320 this.#to.deleteEdgeIn(this)
321 }
322 this.#to = newTo
323 this.#error = null
324 if (this.#to) {
325 this.#to.addEdgeIn(this)
326 }
327 } else if (hard) {
328 this.#error = null
329 } else if (needToUpdateOverrideSet && this.#to) {
330 // Propagate the new override set to the target node.
331 this.#to.updateOverridesEdgeInRemoved(oldOverrideSet)
332 this.#to.updateOverridesEdgeInAdded(newOverrideSet)
333 }
334 }
335
336 detach () {
337 this.#explanation = null
338 if (this.#to) {
339 this.#to.deleteEdgeIn(this)
340 }
341 this.#from?.edgesOut.delete(this.#name)
342 this.#to = null
343 this.#error = 'DETACHED'
344 this.#from = null
345 }
346
347 get from () {
348 return this.#from
349 }
350
351 get to () {
352 return this.#to
353 }
354
355 toJSON () {
356 return new ArboristEdge(this)
357 }
358
359 [util.inspect.custom] () {
360 return this.toJSON()
361 }
362}
363
364module.exports = Edge
365 