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

import { promises as fs, exists } from 'fs'; import * as path from 'path'; import * as lockfile from '@yarnpkg/lockfile'; import * as semver from 'semver'; import { hoistDependencies } from './hoisting'; import { isPackage, iterDeps, type PackageJson, type PackageLockFile, type PackageLockPackage, type PackageLockTree, type YarnLock } from './types'; export interface ShrinkwrapOptions { /** * The package.json file to start scanning for dependencies */ packageJsonFile: string; /** * The output lockfile to generate * * @default - Don't generate the file, just return the calculated output */ outputFile?: string; /** * Whether to hoist dependencies * * @default true */ hoist?: boolean; } export async function generateShrinkwrap(options: ShrinkwrapOptions): Promise<PackageLockFile> { // No args (yet) const packageJsonFile = options.packageJsonFile; const packageJsonDir = path.dirname(packageJsonFile); const yarnLockLoc = await findYarnLock(packageJsonDir); const yarnLock: YarnLock = lockfile.parse(await fs.readFile(yarnLockLoc, { encoding: 'utf8' })); const pkgJson = await loadPackageJson(packageJsonFile); let lock = await generateLockFile(pkgJson, yarnLock, packageJsonDir); if (options.hoist ?? true) { lock = hoistDependencies(lock); } _validateTree(lock); if (options.outputFile) { // Write the shrinkwrap file await fs.writeFile(options.outputFile, JSON.stringify(lock, undefined, 2), { encoding: 'utf8' }); } return lock; } async function generateLockFile(pkgJson: PackageJson, yarnLock: YarnLock, rootDir: string): Promise<PackageLockFile> { const builder = new PackageGraphBuilder(yarnLock); const rootKeys = await builder.buildGraph(pkgJson.dependencies || {}, rootDir); const lockFile: PackageLockFile = { name: pkgJson.name, version: pkgJson.version, lockfileVersion: 1, requires: true, dependencies: builder.makeDependencyTree(rootKeys), }; checkRequiredVersions(lockFile); return lockFile; } class PackageGraphBuilder { public readonly graph = new PackageGraph(); private readonly reportedCycles = new Set<string>(); constructor(private readonly yarnLock: YarnLock) { } public buildGraph(deps: Record<string, string>, rootDir: string) { return this.resolveMap(deps, rootDir, ['root']); } /** * Render the tree by starting from the root keys and recursing, pushing every package as high as it can * go without conflicting */ public makeDependencyTree(rootKeys: string[]): Record<string, PackageLockPackage> { // A shadow tree of { package -> scope } const scopeTree = new Map<PackageLockPackage, { parent?: PackageLockPackage; name: string; consumed: Set<string>; }>(); const root: PackageLockPackage = { version: '*', dependencies: {}, }; scopeTree.set(root, { name: 'root', consumed: new Set() }); type Scope = NonNullableKeys<ReturnType<typeof scopeTree.get>>; // Queue of ids and parents where they should be inserted const queue: Array<[string, PackageLockPackage]> = rootKeys.map(key => [key, root]); while (queue.length > 0) { const [nextId, consumerPkg] = queue.shift()!; const [name, pkg] = this.graph.node(nextId); const consumerScope = scopeTree.get(consumerPkg)!; // --- Step 1: find a place to provide this package anywhere up the tree ----------- if (versionInScope(consumerPkg, name) !== pkg.version) { const packageObject = { ...pkg }; // Make a copy for safety // Otherwise insert the dependency as high up as it'll go without conflicting with other consumed packages let finalParent = consumerPkg; let finalParentScope = consumerScope; // Push that dependency up as far as it'll go (leaving a trail of 'inScope's) while (finalParentScope.parent && !scopeTree.get(finalParentScope.parent)!.consumed.has(name)) { finalParent = finalParentScope.parent; finalParentScope = scopeTree.get(finalParent)!; } // Record location if (finalParent.dependencies?.[name]) { throw new Error('ruh-roh, conflict!'); } finalParent.dependencies = { ...finalParent.dependencies, [name]: packageObject, }; const newPackageScope: Scope = { name: nextId, parent: finalParent, consumed: new Set() }; scopeTree.set(packageObject, newPackageScope); // Add the current package's dependencies to itself for (const child of this.graph.edges(nextId)) { queue.push([child, packageObject]); } } // ---- Step 2, regardless of whether we add a producer or not: mark this consumed all the way up to the root ---------- let consumingScope: Scope | undefined = consumerScope; while (consumingScope) { consumingScope.consumed.add(name); consumingScope = consumingScope.parent ? scopeTree.get(consumingScope.parent) : undefined; } } return Object.fromEntries(iterDeps(root)); function versionInScope(p: PackageLockPackage, name: string): string | undefined { let x: PackageLockPackage | undefined = p; while (x) { if (isPackage(x.dependencies?.[name])) { return x.dependencies[name].version; } x = scopeTree.get(x)?.parent; } return undefined; } } private async resolveMap(deps: Record<string, string>, searchDir: string, rootPath: string[]): Promise<string[]> { const ret: string[] = []; for (const [depName, versionRange] of Object.entries(deps)) { const child = await this.resolve(depName, versionRange, searchDir, rootPath); if (child !== 'cycle') { ret.push(child); } } return ret; } /** * Resolve a dependency and add it to the graph, returning its key */ private async resolve(depName: string, versionRange: string, searchDir: string, rootPath: string[]): Promise<string | 'cycle'> { // Get rid of any monorepo symlinks searchDir = await fs.realpath(searchDir); const dupeIndex = rootPath.findIndex(([name, _]) => name === depName); if (dupeIndex > -1) { const beforeCycle = rootPath.slice(0, dupeIndex); const inCycle = [...rootPath.slice(dupeIndex), depName]; const cycleString = inCycle.join(' => '); if (!this.reportedCycles.has(cycleString)) { // eslint-disable-next-line no-console console.warn(`Dependency cycle: ${beforeCycle.join(' => ')} => [ ${cycleString} ]. Dropping dependency '${inCycle.slice(-2).join(' => ')}'.`); this.reportedCycles.add(cycleString); } return 'cycle'; } const depDir = await findPackageDir(depName, searchDir); const depPkgJsonFile = path.join(depDir, 'package.json'); const depPkgJson = await loadPackageJson(depPkgJsonFile); const yarnKey = `${depName}@${versionRange}`; // Sanity check (does not apply if the version range starts with npm: because then we can alias packages) if (depPkgJson.name !== depName && !versionRange.startsWith('npm:')) { throw new Error(`Looking for '${depName}' from ${searchDir}, but found '${depPkgJson.name}' in ${depDir}`); } let pkg; const yarnResolved = this.yarnLock.object[yarnKey]; if (yarnResolved) { // Resolved by Yarn pkg = noUndefined({ version: yarnResolved.version, integrity: yarnResolved.integrity, resolved: yarnResolved.resolved, requires: notEmpty(depPkgJson.dependencies), }); } else { // Comes from monorepo, just use whatever's in package.json pkg = noUndefined({ version: depPkgJson.version, requires: notEmpty(depPkgJson.dependencies), }); } const prevKey = this.graph.has(depName, pkg); if (prevKey) { return prevKey; } const key = this.graph.addNode(depName, pkg); for (const childKey of await this.resolveMap(depPkgJson.dependencies ?? {}, depDir, [depName, ...rootPath])) { this.graph.addEdge(key, childKey); } return key; } } // eslint-disable-next-line max-len async function findYarnLock(start: string) { return findUp('yarn.lock', start); } async function findUp(fileName: string, start: string) { start = path.resolve(start); let dir = start; const yarnLockHere = () => path.join(dir, fileName); while (!await fileExists(yarnLockHere())) { const parent = path.dirname(dir); if (parent === dir) { throw new Error(`No ${fileName} found upwards from ${start}`); } dir = parent; } return yarnLockHere(); } async function loadPackageJson(fileName: string): Promise<PackageJson> { return JSON.parse(await fs.readFile(fileName, { encoding: 'utf8' })); } async function fileExists(fullPath: string): Promise<boolean> { try { await fs.stat(fullPath); return true; } catch (e: any) { if (e.code === 'ENOENT' || e.code === 'ENOTDIR') { return false; } throw e; } } export function formatPackageLock(entry: PackageLockTree) { const lines = new Array<string>(); recurse([], entry); return lines.join('\n'); function recurse(names: string[], thisEntry: PackageLockTree) { if (names.length > 0) { // eslint-disable-next-line no-console lines.push(`${names.join(' -> ')} @ ${thisEntry.version}`); } for (const [depName, depEntry] of iterDeps(thisEntry)) { recurse([...names, depName], depEntry); } } } /** * Find package directory * * Do this by walking upwards in the directory tree until we find * `<dir>/node_modules/<package>/package.json`. * * ------- * * Things that we tried but don't work: * * 1. require.resolve(`${depName}/package.json`, { paths: [rootDir] }); * * Breaks with ES Modules if `package.json` has not been exported, which is * being enforced starting Node >= 12. * * 2. findPackageJsonUpwardFrom(require.resolve(depName, { paths: [rootDir] })) * * Breaks if a built-in NodeJS package name conflicts with an NPM package name * (in Node15 `string_decoder` is introduced...) */ async function findPackageDir(depName: string, rootDir: string) { let prevDir; let dir = rootDir; while (dir !== prevDir) { const candidateDir = path.join(dir, 'node_modules', depName); if (await new Promise(ok => exists(path.join(candidateDir, 'package.json'), ok))) { return candidateDir; } prevDir = dir; dir = path.dirname(dir); // dirname('/') -> '/', dirname('c:\\') -> 'c:\\' } throw new Error(`Did not find '${depName}' upwards of '${rootDir}'`); } /** * We may sometimes try to adjust a package version to a version that's incompatible with the declared requirement. * * For example, this recently happened for 'netmask', where the package we * depend on has `{ requires: { netmask: '^1.0.6', } }`, but we need to force-substitute in version `2.0.1`. * * If NPM processes the shrinkwrap and encounters the following situation: * * ``` * { * netmask: { version: '2.0.1' }, * resolver: { * requires: { * netmask: '^1.0.6' * } * } * } * ``` * * NPM is going to disregard the swhinkrwap and still give `resolver` its own private * copy of netmask `^1.0.6`. * * We tried overriding the `requires` version, and that works for `npm install` (yay) * but if anyone runs `npm ls` afterwards, `npm ls` is going to check the actual source * `package.jsons` against the actual `node_modules` file tree, and complain that the * versions don't match. * * We run `npm ls` in our tests to make sure our dependency tree is sane, and our customers * might too, so this is not a great solution. * * To cut any discussion short in the future, we're going to detect this situation and * tell our future selves that is cannot and will not work, and we should find another * solution. */ export function checkRequiredVersions(root: PackageLockFile) { recurse(root, [[root.name, root]]); // rootPath does include 'entry' function recurse(entry: PackageLockFile | PackageLockPackage, rootPath: RootPath) { // On the root, 'requires' is the value 'true', for God knows what reason. Don't care about those. if (typeof entry.requires === 'object') { // For every 'requires' dependency, find the version it actually got resolved to and compare. for (let [name, range] of Object.entries(entry.requires)) { const resolvedRet = findResolved(name, rootPath); if (!resolvedRet) { continue; } const [resolvedPackage, resolvedPath] = resolvedRet; if (range.includes('@')) { // For alias packages range = range.split('@')[1]; } const depPath = [name, ...rootPath.map(x => x[0])]; if (!semver.satisfies(resolvedPackage.version, range)) { // Ruh-roh. throw new Error(`Looks like we're trying to force '${renderRootPath(depPath)}' to version '${resolvedPackage.version}' (found at ${resolvedPath}), but the dependency ` + `is specified as '${range}'. This can never properly work via shrinkwrapping. Try vendoring a patched ` + 'version of the intermediary dependencies instead.'); } } } for (const [name, dep] of iterDeps(entry)) { recurse(dep, [[name, dep], ...rootPath]); } } /** * Find a package name in a package lock tree. */ function findResolved(name: string, chain: RootPath): [PackageLockPackage, string] | undefined { for (let i = 0; i < chain.length; i++) { const level = chain[i][1]; if (level.dependencies?.[name] && level.dependencies?.[name] !== 'moved') { return [level.dependencies?.[name], renderRootPath(chain.slice(i))]; } } return undefined; } } /** * Check that all packages still resolve their dependencies to the right versions * * We have manipulated the tree a bunch. Do a sanity check to ensure that all declared * dependencies are satisfied. */ export function _validateTree(lock: PackageLockTree) { const errors = new Array<string>(); recurse(lock, [['root', lock]], {}); if (errors.length > 0) { throw new Error(`Could not satisfy one or more dependencies:\n${errors.join('\n')}`); } // rootPath does include pkg function recurse(pkg: PackageLockTree, rootPath: RootPath, inheritedDepsVersions: Record<string, string>) { const depsVersionsHere = { ...inheritedDepsVersions, ...Object.fromEntries(iterDeps(pkg).map(([name, pack]) => [name, pack.version])), }; for (const [name, expectedVersion] of Object.entries(pkg.requires ?? {})) { checkRequiresOf(name, expectedVersion, depsVersionsHere, rootPath); } for (const [name, pack] of iterDeps(pkg)) { const p: RootPath = [[name, pack], ...rootPath]; recurse(pack, p, depsVersionsHere); } } // rootPath: most specific one first, should NOT include name function checkRequiresOf(name: string, declaredRange: string, depsVersions: Record<string, string>, rootPath: RootPath) { if (declaredRange.includes('@')) { // For alias packages declaredRange = declaredRange.split('@')[1]; } const foundVersion = depsVersions[name]; const newRootPath = [name, ...rootPath.map(x => x[0])]; if (!foundVersion) { errors.push(`Dependency on ${renderRootPath(newRootPath)} not satisfied: not found`); } else if (!semver.satisfies(foundVersion, declaredRange)) { // eslint-disable-next-line no-console errors.push(`Dependency on ${renderRootPath(newRootPath)} not satisfied: declared range '${declaredRange}', found '${foundVersion}'`); } } } function notEmpty<A extends object>(x: A | undefined): A | undefined { return x && Object.keys(x).length > 0 ? x : undefined; } function noUndefined<A extends object>(xs: A): NonNullableKeys<A> { return Object.fromEntries(Object.entries(xs).filter(([_, v]) => v !== undefined)) as any; } type NonNullableKeys<T> = { [P in keyof T as undefined extends T[P] ? P : never]?: NonNullable<T[P]> } & { [P in keyof T as undefined extends T[P] ? never : P]: T[P] } // RootPath is always reversed (i.e. closest first) type RootPath = Array<[string, PackageLockTree]>; function renderRootPath(p: RootPath | string[]) { return p.map(x => Array.isArray(x)? x[0] : x).reverse().join(' => '); } class PackageGraph { private readonly nodes = new Map<string, [string, Omit<PackageLockPackage, 'dependencies'>]>(); private readonly _edges = new Map<string, string[]>(); public key(name: string, pkg: PackageLockPackage) { return `${name}@${pkg.version}`; } public has(name: string, pkg: PackageLockPackage): string | undefined { const key = this.key(name, pkg); return this.nodes.has(key) ? key : undefined; } public addNode(name: string, pkg: PackageLockPackage) { const key = this.key(name, pkg); if (this.nodes.has(key)) { throw new Error(`Package already in graph: ${key}`); } const copy = { ...pkg }; delete copy.dependencies; this.nodes.set(key, [name, copy]); return key; } public addEdge(parent: string, child: string) { let edges = this._edges.get(parent); if (!edges) { edges = []; this._edges.set(parent, edges); } edges.push(child); } public node(key: string) { const x = this.nodes.get(key); if (!x) { throw new Error(`No such package: ${key}`); } return x; } public edges(parent: string) { return Array.from(new Set(this._edges.get(parent) ?? [])); } public toGraphviz() { const lines = ['digraph {']; // Add all nodes for (const [key, [name, pkg]] of this.nodes.entries()) { lines.push(` "${key}" [label="${name}@${pkg.version}"];`); } // Add all edges for (const [parent, children] of this._edges.entries()) { for (const child of children) { lines.push(` "${parent}" -> "${child}";`); } } lines.push('}'); return lines.join('\n'); } }