Skip to content

Instantly share code, notes, and snippets.

@ciiqr
Last active February 12, 2023 18:59
Show Gist options
  • Save ciiqr/db299289f40f0e6dbe267513b0e61c0f to your computer and use it in GitHub Desktop.
Save ciiqr/db299289f40f0e6dbe267513b0e61c0f to your computer and use it in GitHub Desktop.
find package conflicts in a monorepo's package-lock.json
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