packages/@aws-cdk/yarn-cling/lib/hoisting.ts (148 lines of code) (raw):

import { _validateTree } from '.'; import { iterDeps, isPackage, type PackageLockFile, type PackageLockTree } from './types'; /** * Hoist package-lock dependencies in-place * * This happens in two phases: * * 1) Move every package into the parent scope (as long as it introduces no conflicts). * Leave "moved" markers to indicate that a package used to be there and no * new package with the same name should be moved up into that location. * 2) Once no more packages can be moved up, clean up the tree. This step mutates the * tree declarations but cannot change versions of required packages. Two cleanups: * 2a) Remove duplicates down the tree (same version that is inherited from above) * 2b) Remove useless packages that aren't depended upon by anything in that subtree. * * This two-phase process replaces a proces that did move-and-delete as one step, which * sometimes would hoist a package into a place that was previously vacated by a conflicting * version, thereby causing the wrong version to be loaded. * * Hoisting is still rather expensive on a large tree (~100ms), we should find ways to * speed it up. */ export function hoistDependencies(packageTree: PackageLockFile): PackageLockFile { let tree = packageTree; tree = _pushDepsToParent(tree); tree = _removeDupesWithParent(tree); tree = _removeTombstones(tree); tree = _removeUseless(tree, packageTree); return tree; } export function renderTree(tree: PackageLockTree): string[] { const ret = new Array<string>(); recurse(tree, []); return ret.sort(compareSplit); function recurse(x: PackageLockTree, parts: string[]) { for (const [k, v] of Object.entries(x.dependencies ?? {})) { ret.push([...parts, k].join('.') + '=' + (isPackage(v) ? v.version : '...')); if (isPackage(v)) { recurse(v, [...parts, k]); } } } function compareSplit(a: string, b: string): number { // Sort so that: 'a=1', 'a.b=2' get sorted in that order. const as = a.split(/\.|=/g); const bs = b.split(/\.|=/g); for (let i = 0; i < as.length && i < bs.length; i++) { const cmp = as[i].localeCompare(bs[i]); if (cmp !== 0) { return cmp; } } return as.length - bs.length; } } export function _pushDepsToParent<A extends PackageLockTree>(root: A): A { let tree = structuredClone(root); while (recurse(tree)) { } return tree; function recurse(node: PackageLockTree, parent?: PackageLockTree): boolean { if (parent) { for (const [name, dep] of iterDeps(node)) { if (!parent.dependencies![name]) { parent.dependencies![name] = dep; node.dependencies![name] = 'moved'; return true; } } } for (const [_, dep] of iterDeps(node)) { if (recurse(dep, node)) { return true; } } return false; } } // Move dependencies up a level if there is no conflict export function _pushDepsToParent0<A extends PackageLockTree>(root: A): A { root = structuredClone(root); postOrderRecurse(root, (node, parent) => { if (!parent) { return; } for (const [depName, depPackage] of iterDeps(node)) { // Move the package up if (!parent?.dependencies?.[depName]) { parent.dependencies![depName] = structuredClone(depPackage); node.dependencies![depName] = 'moved'; } } }); return root; } export function _removeDupesWithParent<A extends PackageLockTree>(root: A): A { root = structuredClone(root); postOrderRecurse(root, (node, parent) => { if (!node.dependencies || !parent) { return; } for (const [depName, depPackage] of iterDeps(node)) { // Any dependencies here that are the same in the parent can be removed const parentDep = parent.dependencies![depName]; if (isPackage(parentDep) && parentDep.version === depPackage.version) { delete node.dependencies[depName]; } } }); return root; } function _removeUseless<A extends PackageLockTree>(root: A, originalTree: A): A { if (originalTree.requires === true) { const topLevelDependencies = Object.keys(originalTree.dependencies ?? {}); // Temporarily replace 'requires' with the set of original dependencies so // that the '_removeUseless' op will not shake them. root.requires = Object.fromEntries(topLevelDependencies.map((dep) => [dep, '*'])); } root = structuredClone(root); recurse(root); if (originalTree.requires === true) { // Put the 'true' back root.requires = true; } return root; function recurse(node: PackageLockTree): Set<string> { const requiredHere = new Set<string>(Object.keys(node.requires ?? {})); // Build a { dependency -> required* } map for every dependency const requiredByDeps = new Map<string, Set<string>>(iterDeps(node).map(([name, pack]) => [name, recurse(pack)])); // Peel deps off the `requiredByDeps` map until we can't anymore let allRequires = setUnion(requiredHere, ...requiredByDeps.values()); let changed; do { changed = false; for (const depName of requiredByDeps.keys()) { if (!allRequires.has(depName)) { requiredByDeps.delete(depName); delete node.dependencies![depName]; changed = true; allRequires = setUnion(requiredHere, ...requiredByDeps.values()); } } } while (changed); if (Object.keys(node.dependencies ?? {}).length === 0) { delete node.dependencies; } return allRequires; } } /** * Remove the 'moved' markers */ function _removeTombstones<A extends PackageLockTree>(root: A): A { postOrderRecurse(root, (node) => { for (const [name, v] of Object.entries(node.dependencies ?? {})) { if (v === 'moved') { delete node.dependencies![name]; } } }); return root; } function postOrderRecurse(root: PackageLockTree, block: (node: PackageLockTree, parent?: PackageLockTree) => void) { recurse(root); function recurse(node: PackageLockTree, parent?: PackageLockTree) { for (const [_, child] of iterDeps(node)) { recurse(child, node); } block(node, parent); } } function setUnion<A>(...xss: Array<Set<A>>): Set<A> { const ret = new Set<A>(); for (const xs of xss) { for (const x of xs) { ret.add(x); } } return ret; }