• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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