Mini Shell
// a tree representing the difference between two trees
// A Diff node's parent is not necessarily the parent of
// the node location it refers to, but rather the highest level
// node that needs to be either changed or removed.
// Thus, the root Diff node is the shallowest change required
// for a given branch of the tree being mutated.
const { depth } = require('treeverse')
const { existsSync } = require('fs')
const ssri = require('ssri')
class Diff {
constructor ({ actual, ideal, filterSet, shrinkwrapInflated }) {
this.filterSet = filterSet
this.shrinkwrapInflated = shrinkwrapInflated
this.children = []
this.actual = actual
this.ideal = ideal
if (this.ideal) {
this.resolved = this.ideal.resolved
this.integrity = this.ideal.integrity
}
this.action = getAction(this)
this.parent = null
// the set of leaf nodes that we rake up to the top level
this.leaves = []
// the set of nodes that don't change in this branch of the tree
this.unchanged = []
// the set of nodes that will be removed in this branch of the tree
this.removed = []
}
static calculate ({
actual,
ideal,
filterNodes = [],
shrinkwrapInflated = new Set(),
}) {
// if there's a filterNode, then:
// - get the path from the root to the filterNode. The root or
// root.target should have an edge either to the filterNode or
// a link to the filterNode. If not, abort. Add the path to the
// filterSet.
// - Add set of Nodes depended on by the filterNode to filterSet.
// - Anything outside of that set should be ignored by getChildren
const filterSet = new Set()
const extraneous = new Set()
for (const filterNode of filterNodes) {
const { root } = filterNode
if (root !== ideal && root !== actual) {
throw new Error('invalid filterNode: outside idealTree/actualTree')
}
const rootTarget = root.target
const edge = [...rootTarget.edgesOut.values()].filter(e => {
return e.to && (e.to === filterNode || e.to.target === filterNode)
})[0]
filterSet.add(root)
filterSet.add(rootTarget)
filterSet.add(ideal)
filterSet.add(actual)
if (edge && edge.to) {
filterSet.add(edge.to)
filterSet.add(edge.to.target)
}
filterSet.add(filterNode)
depth({
tree: filterNode,
visit: node => filterSet.add(node),
getChildren: node => {
node = node.target
const loc = node.location
const idealNode = ideal.inventory.get(loc)
const ideals = !idealNode ? []
: [...idealNode.edgesOut.values()].filter(e => e.to).map(e => e.to)
const actualNode = actual.inventory.get(loc)
const actuals = !actualNode ? []
: [...actualNode.edgesOut.values()].filter(e => e.to).map(e => e.to)
if (actualNode) {
for (const child of actualNode.children.values()) {
if (child.extraneous) {
extraneous.add(child)
}
}
}
return ideals.concat(actuals)
},
})
}
for (const extra of extraneous) {
filterSet.add(extra)
}
return depth({
tree: new Diff({ actual, ideal, filterSet, shrinkwrapInflated }),
getChildren,
leave,
})
}
}
const getAction = ({ actual, ideal }) => {
if (!ideal) {
return 'REMOVE'
}
// bundled meta-deps are copied over to the ideal tree when we visit it,
// so they'll appear to be missing here. There's no need to handle them
// in the diff, though, because they'll be replaced at reify time anyway
// Otherwise, add the missing node.
if (!actual) {
return ideal.inDepBundle ? null : 'ADD'
}
// always ignore the root node
if (ideal.isRoot && actual.isRoot) {
return null
}
// if the versions don't match, it's a change no matter what
if (ideal.version !== actual.version) {
return 'CHANGE'
}
const binsExist = ideal.binPaths.every((path) => existsSync(path))
// top nodes, links, and git deps won't have integrity, but do have resolved
// if neither node has integrity, the bins exist, and either (a) neither
// node has a resolved value or (b) they both do and match, then we can
// leave this one alone since we already know the versions match due to
// the condition above. The "neither has resolved" case (a) cannot be
// treated as a 'mark CHANGE and refetch', because shrinkwraps, bundles,
// and link deps may lack this information, and we don't want to try to
// go to the registry for something that isn't there.
const noIntegrity = !ideal.integrity && !actual.integrity
const noResolved = !ideal.resolved && !actual.resolved
const resolvedMatch = ideal.resolved && ideal.resolved === actual.resolved
if (noIntegrity && binsExist && (resolvedMatch || noResolved)) {
return null
}
// otherwise, verify that it's the same bits
// note that if ideal has integrity, and resolved doesn't, we treat
// that as a 'change', so that it gets re-fetched and locked down.
const integrityMismatch = !ideal.integrity || !actual.integrity ||
!ssri.parse(ideal.integrity).match(actual.integrity)
if (integrityMismatch || !binsExist) {
return 'CHANGE'
}
return null
}
const allChildren = node => {
if (!node) {
return new Map()
}
// if the node is root, and also a link, then what we really
// want is to traverse the target's children
if (node.isRoot && node.isLink) {
return allChildren(node.target)
}
const kids = new Map()
for (const n of [node, ...node.fsChildren]) {
for (const kid of n.children.values()) {
kids.set(kid.path, kid)
}
}
return kids
}
// functions for the walk options when we traverse the trees
// to create the diff tree
const getChildren = diff => {
const children = []
const {
actual,
ideal,
unchanged,
removed,
filterSet,
shrinkwrapInflated,
} = diff
// Note: we DON'T diff fsChildren themselves, because they are either
// included in the package contents, or part of some other project, and
// will never appear in legacy shrinkwraps anyway. but we _do_ include the
// child nodes of fsChildren, because those are nodes that we are typically
// responsible for installing.
const actualKids = allChildren(actual)
const idealKids = allChildren(ideal)
if (ideal && ideal.hasShrinkwrap && !shrinkwrapInflated.has(ideal)) {
// Guaranteed to get a diff.leaves here, because we always
// be called with a proper Diff object when ideal has a shrinkwrap
// that has not been inflated.
diff.leaves.push(diff)
return children
}
const paths = new Set([...actualKids.keys(), ...idealKids.keys()])
for (const path of paths) {
const actual = actualKids.get(path)
const ideal = idealKids.get(path)
diffNode({
actual,
ideal,
children,
unchanged,
removed,
filterSet,
shrinkwrapInflated,
})
}
if (diff.leaves && !children.length) {
diff.leaves.push(diff)
}
return children
}
const diffNode = ({
actual,
ideal,
children,
unchanged,
removed,
filterSet,
shrinkwrapInflated,
}) => {
if (filterSet.size && !(filterSet.has(ideal) || filterSet.has(actual))) {
return
}
const action = getAction({ actual, ideal })
// if it's a match, then get its children
// otherwise, this is the child diff node
if (action || (!shrinkwrapInflated.has(ideal) && ideal.hasShrinkwrap)) {
if (action === 'REMOVE') {
removed.push(actual)
}
children.push(new Diff({ actual, ideal, filterSet, shrinkwrapInflated }))
} else {
unchanged.push(ideal)
// !*! Weird dirty hack warning !*!
//
// Bundled deps aren't loaded in the ideal tree, because we don't know
// what they are going to be without unpacking. Swap them over now if
// the bundling node isn't changing, so we don't prune them later.
//
// It's a little bit dirty to be doing this here, since it means that
// diffing trees can mutate them, but otherwise we have to walk over
// all unchanging bundlers and correct the diff later, so it's more
// efficient to just fix it while we're passing through already.
//
// Note that moving over a bundled dep will break the links to other
// deps under this parent, which may have been transitively bundled.
// Breaking those links means that we'll no longer see the transitive
// dependency, meaning that it won't appear as bundled any longer!
// In order to not end up dropping transitively bundled deps, we have
// to get the list of nodes to move, then move them all at once, rather
// than moving them one at a time in the first loop.
const bd = ideal.package.bundleDependencies
if (actual && bd && bd.length) {
const bundledChildren = []
for (const node of actual.children.values()) {
if (node.inBundle) {
bundledChildren.push(node)
}
}
for (const node of bundledChildren) {
node.parent = ideal
}
}
children.push(...getChildren({
actual,
ideal,
unchanged,
removed,
filterSet,
shrinkwrapInflated,
}))
}
}
// set the parentage in the leave step so that we aren't attaching
// child nodes only to remove them later. also bubble up the unchanged
// nodes so that we can move them out of staging in the reification step.
const leave = (diff, children) => {
children.forEach(kid => {
kid.parent = diff
diff.leaves.push(...kid.leaves)
diff.unchanged.push(...kid.unchanged)
diff.removed.push(...kid.removed)
})
diff.children = children
return diff
}
module.exports = Diff
Zerion Mini Shell 1.0