Last active
February 12, 2023 18:59
-
-
Save ciiqr/db299289f40f0e6dbe267513b0e61c0f to your computer and use it in GitHub Desktop.
find package conflicts in a monorepo's package-lock.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fs from "fs/promises"; | |
import { minimatch } from "minimatch"; | |
import { SemVer, parse, satisfies } from "semver"; | |
import { z } from "zod"; | |
type Package = (typeof packages)[number]; | |
function isNonNullable<T>(val: T): val is NonNullable<T> { | |
return Boolean(val); | |
} | |
function onlyUnique<T>(value: T, index: number, array: T[]) { | |
return array.indexOf(value) === index; | |
} | |
function getWorkspacePath(pkg: Package) { | |
return pkg.path.replace(new RegExp(`/node_modules/${pkg.name}$`, "u"), ""); | |
} | |
// package lock schema | |
const schema = z.object({ | |
name: z.string(), | |
packages: z.record( | |
z.string(), | |
z.object({ | |
version: z | |
.string() | |
.transform((version) => parse(version) ?? undefined) | |
.optional(), | |
workspaces: z.array(z.string()).optional(), | |
dependencies: z.record(z.string(), z.string()).optional(), | |
devDependencies: z.record(z.string(), z.string()).optional(), | |
peerDependencies: z.record(z.string(), z.string()).optional(), | |
}), | |
), | |
}); | |
// load package lock | |
const path = process.argv[2]; | |
if (!path) { | |
console.error("conflicts: missing <path>"); | |
process.exit(1); | |
} | |
const packageLockContents = (await fs.readFile(path)).toString(); | |
const packageLockJson: unknown = JSON.parse(packageLockContents); | |
const packageLock = schema.parse(packageLockJson); | |
// find monorepo root package | |
const monorepoRootPackage = packageLock.packages[""]; | |
if (!monorepoRootPackage) { | |
console.error("couldn't find monorepo root package"); | |
process.exit(1); | |
} | |
// workspace paths (with leading ./ trimmed) | |
const workspacePaths = (monorepoRootPackage.workspaces ?? []).map((s) => | |
s.replace(/^.\//u, ""), | |
); | |
// map packages | |
const packages = Object.entries(packageLock.packages).map(([path, pkg]) => { | |
const isSubNodeModule = /node_modules\/.*\/node_modules\//u.test(path); | |
return { | |
...pkg, | |
version: pkg.version ?? new SemVer("0.0.0"), | |
name: path.replace(/.*\/node_modules\//u, ""), | |
path, | |
isConflict: path.includes("/node_modules/") && !isSubNodeModule, | |
isTopLevel: path.startsWith("node_modules/") && !isSubNodeModule, | |
isWorkspace: workspacePaths.some((w) => minimatch(path, w)), | |
}; | |
}); | |
// find conflicts | |
const conflicts = packages.filter((p) => p.isConflict); | |
function packageDependsOnVersion(pkg: Package, dependency: Package) { | |
return ( | |
pkg.dependencies?.[dependency.name] ?? | |
pkg.peerDependencies?.[dependency.name] ?? | |
pkg.devDependencies?.[dependency.name] | |
); | |
} | |
function getDependents(current: Package) { | |
const workspacePath = getWorkspacePath(current); | |
// find top level instance of this package | |
const topLevelPackage = packages.find( | |
(p) => p.isTopLevel && p.name === current.name, | |
); | |
return ( | |
packages | |
// find general dependents on this package (ignoring version) | |
.map((pkg) => { | |
const dependentVersion = packageDependsOnVersion(pkg, current); | |
if (!dependentVersion) { | |
return undefined; | |
} | |
return { | |
pkg, | |
dependentVersion, | |
}; | |
}) | |
.filter(isNonNullable) | |
// find dependents within the same workspace (or in the root node_modules) | |
.filter( | |
({ pkg }) => | |
pkg.isTopLevel || pkg.path.startsWith(workspacePath), | |
) | |
// satisfied by the current version | |
.filter(({ dependentVersion }) => | |
satisfies(current.version, dependentVersion), | |
) | |
// doesn't satisfy the top level version of this package | |
.filter( | |
({ dependentVersion }) => | |
!topLevelPackage || | |
!satisfies(topLevelPackage.version, dependentVersion), | |
) | |
// just the package | |
.map(({ pkg }) => pkg) | |
); | |
} | |
type Path = Package[]; | |
function getDependencyPaths(current: Package): Path[] { | |
// once we get to a workspace package, we've discovered the whole dep path | |
if (current.isWorkspace) { | |
return []; | |
} | |
// find all packages which depend on the current package | |
const dependents = getDependents(current); | |
return ( | |
dependents | |
// recurse up all dependents | |
.map((pkg) => [pkg, ...getDependencyPaths(pkg).flat()]) | |
// we only want to show paths that end at a workspace... | |
.filter((path) => path[path.length - 1]?.isWorkspace) | |
); | |
} | |
// collect conflict dependency paths | |
const conflictDependencyPaths = conflicts.map((conflict) => ({ | |
conflict, | |
paths: getDependencyPaths(conflict).map((p) => [conflict, ...p]), | |
})); | |
// collect direct dependency conflicts | |
const directDependencyConflicts = conflictDependencyPaths | |
.flatMap(({ paths }) => | |
paths.flatMap((p) => | |
// last non-workspace part should be the direct dependency that is causing the conflict | |
{ | |
// eslint-disable-next-line max-nested-callbacks | |
const nonWorkspacePath = p.filter((p) => !p.isWorkspace); | |
const directDependency = | |
nonWorkspacePath[nonWorkspacePath.length - 1]; | |
if (!directDependency) { | |
throw new Error( | |
"Unreachable: dependency paths will always at least contain the conflict itself", | |
); | |
} | |
return directDependency; | |
}, | |
), | |
) | |
.filter(onlyUnique) // often multiple conflicts will be caused by a single dependency... | |
.sort((a, b) => a.name.localeCompare(b.name)); | |
// show dep paths | |
for (const { conflict, paths } of conflictDependencyPaths) { | |
console.log( | |
`% conflict ${conflict.path} (${conflict.version.format()}) due to:`, | |
); | |
for (const path of paths) { | |
console.log( | |
` - ${path | |
.map((p) => `${p.name} (${p.version.format()})`) | |
.join(" -> ")}`, | |
); | |
} | |
} | |
// show direct dependency conflicts | |
if (directDependencyConflicts.length > 0) { | |
console.log(); | |
console.log("% Summary"); | |
} | |
for (const directDependencyConflict of directDependencyConflicts) { | |
const workspacePath = getWorkspacePath(directDependencyConflict); | |
console.log( | |
` - ${ | |
directDependencyConflict.name | |
} (${directDependencyConflict.version.format()}) in ${workspacePath}`, | |
); | |
} | |
// exit code | |
const exitCode = directDependencyConflicts.length > 0 ? 1 : 0; | |
process.exit(exitCode); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment