1// a tree representing the difference between two trees 2// A Diff node's parent is not necessarily the parent of 3// the node location it refers to, but rather the highest level 4// node that needs to be either changed or removed. 5// Thus, the root Diff node is the shallowest change required 6// for a given branch of the tree being mutated. 7 8const { depth } = require('treeverse') 9const { existsSync } = require('fs') 10 11const ssri = require('ssri') 12 13class Diff { 14 constructor ({ actual, ideal, filterSet, shrinkwrapInflated }) { 15 this.filterSet = filterSet 16 this.shrinkwrapInflated = shrinkwrapInflated 17 this.children = [] 18 this.actual = actual 19 this.ideal = ideal 20 if (this.ideal) { 21 this.resolved = this.ideal.resolved 22 this.integrity = this.ideal.integrity 23 } 24 this.action = getAction(this) 25 this.parent = null 26 // the set of leaf nodes that we rake up to the top level 27 this.leaves = [] 28 // the set of nodes that don't change in this branch of the tree 29 this.unchanged = [] 30 // the set of nodes that will be removed in this branch of the tree 31 this.removed = [] 32 } 33 34 static calculate ({ 35 actual, 36 ideal, 37 filterNodes = [], 38 shrinkwrapInflated = new Set(), 39 }) { 40 // if there's a filterNode, then: 41 // - get the path from the root to the filterNode. The root or 42 // root.target should have an edge either to the filterNode or 43 // a link to the filterNode. If not, abort. Add the path to the 44 // filterSet. 45 // - Add set of Nodes depended on by the filterNode to filterSet. 46 // - Anything outside of that set should be ignored by getChildren 47 const filterSet = new Set() 48 const extraneous = new Set() 49 for (const filterNode of filterNodes) { 50 const { root } = filterNode 51 if (root !== ideal && root !== actual) { 52 throw new Error('invalid filterNode: outside idealTree/actualTree') 53 } 54 const rootTarget = root.target 55 const edge = [...rootTarget.edgesOut.values()].filter(e => { 56 return e.to && (e.to === filterNode || e.to.target === filterNode) 57 })[0] 58 filterSet.add(root) 59 filterSet.add(rootTarget) 60 filterSet.add(ideal) 61 filterSet.add(actual) 62 if (edge && edge.to) { 63 filterSet.add(edge.to) 64 filterSet.add(edge.to.target) 65 } 66 filterSet.add(filterNode) 67 68 depth({ 69 tree: filterNode, 70 visit: node => filterSet.add(node), 71 getChildren: node => { 72 node = node.target 73 const loc = node.location 74 const idealNode = ideal.inventory.get(loc) 75 const ideals = !idealNode ? [] 76 : [...idealNode.edgesOut.values()].filter(e => e.to).map(e => e.to) 77 const actualNode = actual.inventory.get(loc) 78 const actuals = !actualNode ? [] 79 : [...actualNode.edgesOut.values()].filter(e => e.to).map(e => e.to) 80 if (actualNode) { 81 for (const child of actualNode.children.values()) { 82 if (child.extraneous) { 83 extraneous.add(child) 84 } 85 } 86 } 87 88 return ideals.concat(actuals) 89 }, 90 }) 91 } 92 for (const extra of extraneous) { 93 filterSet.add(extra) 94 } 95 96 return depth({ 97 tree: new Diff({ actual, ideal, filterSet, shrinkwrapInflated }), 98 getChildren, 99 leave, 100 }) 101 } 102} 103 104const getAction = ({ actual, ideal }) => { 105 if (!ideal) { 106 return 'REMOVE' 107 } 108 109 // bundled meta-deps are copied over to the ideal tree when we visit it, 110 // so they'll appear to be missing here. There's no need to handle them 111 // in the diff, though, because they'll be replaced at reify time anyway 112 // Otherwise, add the missing node. 113 if (!actual) { 114 return ideal.inDepBundle ? null : 'ADD' 115 } 116 117 // always ignore the root node 118 if (ideal.isRoot && actual.isRoot) { 119 return null 120 } 121 122 // if the versions don't match, it's a change no matter what 123 if (ideal.version !== actual.version) { 124 return 'CHANGE' 125 } 126 127 const binsExist = ideal.binPaths.every((path) => existsSync(path)) 128 129 // top nodes, links, and git deps won't have integrity, but do have resolved 130 // if neither node has integrity, the bins exist, and either (a) neither 131 // node has a resolved value or (b) they both do and match, then we can 132 // leave this one alone since we already know the versions match due to 133 // the condition above. The "neither has resolved" case (a) cannot be 134 // treated as a 'mark CHANGE and refetch', because shrinkwraps, bundles, 135 // and link deps may lack this information, and we don't want to try to 136 // go to the registry for something that isn't there. 137 const noIntegrity = !ideal.integrity && !actual.integrity 138 const noResolved = !ideal.resolved && !actual.resolved 139 const resolvedMatch = ideal.resolved && ideal.resolved === actual.resolved 140 if (noIntegrity && binsExist && (resolvedMatch || noResolved)) { 141 return null 142 } 143 144 // otherwise, verify that it's the same bits 145 // note that if ideal has integrity, and resolved doesn't, we treat 146 // that as a 'change', so that it gets re-fetched and locked down. 147 const integrityMismatch = !ideal.integrity || !actual.integrity || 148 !ssri.parse(ideal.integrity).match(actual.integrity) 149 if (integrityMismatch || !binsExist) { 150 return 'CHANGE' 151 } 152 153 return null 154} 155 156const allChildren = node => { 157 if (!node) { 158 return new Map() 159 } 160 161 // if the node is root, and also a link, then what we really 162 // want is to traverse the target's children 163 if (node.isRoot && node.isLink) { 164 return allChildren(node.target) 165 } 166 167 const kids = new Map() 168 for (const n of [node, ...node.fsChildren]) { 169 for (const kid of n.children.values()) { 170 kids.set(kid.path, kid) 171 } 172 } 173 return kids 174} 175 176// functions for the walk options when we traverse the trees 177// to create the diff tree 178const getChildren = diff => { 179 const children = [] 180 const { 181 actual, 182 ideal, 183 unchanged, 184 removed, 185 filterSet, 186 shrinkwrapInflated, 187 } = diff 188 189 // Note: we DON'T diff fsChildren themselves, because they are either 190 // included in the package contents, or part of some other project, and 191 // will never appear in legacy shrinkwraps anyway. but we _do_ include the 192 // child nodes of fsChildren, because those are nodes that we are typically 193 // responsible for installing. 194 const actualKids = allChildren(actual) 195 const idealKids = allChildren(ideal) 196 197 if (ideal && ideal.hasShrinkwrap && !shrinkwrapInflated.has(ideal)) { 198 // Guaranteed to get a diff.leaves here, because we always 199 // be called with a proper Diff object when ideal has a shrinkwrap 200 // that has not been inflated. 201 diff.leaves.push(diff) 202 return children 203 } 204 205 const paths = new Set([...actualKids.keys(), ...idealKids.keys()]) 206 for (const path of paths) { 207 const actual = actualKids.get(path) 208 const ideal = idealKids.get(path) 209 diffNode({ 210 actual, 211 ideal, 212 children, 213 unchanged, 214 removed, 215 filterSet, 216 shrinkwrapInflated, 217 }) 218 } 219 220 if (diff.leaves && !children.length) { 221 diff.leaves.push(diff) 222 } 223 224 return children 225} 226 227const diffNode = ({ 228 actual, 229 ideal, 230 children, 231 unchanged, 232 removed, 233 filterSet, 234 shrinkwrapInflated, 235}) => { 236 if (filterSet.size && !(filterSet.has(ideal) || filterSet.has(actual))) { 237 return 238 } 239 240 const action = getAction({ actual, ideal }) 241 242 // if it's a match, then get its children 243 // otherwise, this is the child diff node 244 if (action || (!shrinkwrapInflated.has(ideal) && ideal.hasShrinkwrap)) { 245 if (action === 'REMOVE') { 246 removed.push(actual) 247 } 248 children.push(new Diff({ actual, ideal, filterSet, shrinkwrapInflated })) 249 } else { 250 unchanged.push(ideal) 251 // !*! Weird dirty hack warning !*! 252 // 253 // Bundled deps aren't loaded in the ideal tree, because we don't know 254 // what they are going to be without unpacking. Swap them over now if 255 // the bundling node isn't changing, so we don't prune them later. 256 // 257 // It's a little bit dirty to be doing this here, since it means that 258 // diffing trees can mutate them, but otherwise we have to walk over 259 // all unchanging bundlers and correct the diff later, so it's more 260 // efficient to just fix it while we're passing through already. 261 // 262 // Note that moving over a bundled dep will break the links to other 263 // deps under this parent, which may have been transitively bundled. 264 // Breaking those links means that we'll no longer see the transitive 265 // dependency, meaning that it won't appear as bundled any longer! 266 // In order to not end up dropping transitively bundled deps, we have 267 // to get the list of nodes to move, then move them all at once, rather 268 // than moving them one at a time in the first loop. 269 const bd = ideal.package.bundleDependencies 270 if (actual && bd && bd.length) { 271 const bundledChildren = [] 272 for (const node of actual.children.values()) { 273 if (node.inBundle) { 274 bundledChildren.push(node) 275 } 276 } 277 for (const node of bundledChildren) { 278 node.parent = ideal 279 } 280 } 281 children.push(...getChildren({ 282 actual, 283 ideal, 284 unchanged, 285 removed, 286 filterSet, 287 shrinkwrapInflated, 288 })) 289 } 290} 291 292// set the parentage in the leave step so that we aren't attaching 293// child nodes only to remove them later. also bubble up the unchanged 294// nodes so that we can move them out of staging in the reification step. 295const leave = (diff, children) => { 296 children.forEach(kid => { 297 kid.parent = diff 298 diff.leaves.push(...kid.leaves) 299 diff.unchanged.push(...kid.unchanged) 300 diff.removed.push(...kid.removed) 301 }) 302 diff.children = children 303 return diff 304} 305 306module.exports = Diff 307