Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type NpmLockPkg = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
dev?: boolean;
optional?: boolean;
resolved?: string;
Expand Down
43 changes: 40 additions & 3 deletions lib/dep-graph-builders/npm-lock-v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DepGraph, DepGraphBuilder } from '@snyk/dep-graph';
import {
addPkgNodeToGraph,
getGraphDependencies,
getPeerDependencies,
getTopLevelDeps,
parsePkgJson,
PkgNode,
Expand Down Expand Up @@ -81,17 +82,26 @@ export const buildDepGraphNpmLockV2 = async (
createNodeInfo(options),
);

// Root peer dependencies are handled here (not via includePeerDeps) so they
// get the same treatment as a package's peers deeper in the tree: optional
// peers are excluded and a missing peer is skipped rather than throwing.
const topLevelDeps = getTopLevelDeps(pkgJson, {
includeDevDeps,
includeOptionalDeps,
includePeerDeps: true,
includePeerDeps: false,
});
const rootPeerDeps = getPeerDependencies(
pkgJson.peerDependencies,
pkgJson.peerDependenciesMeta,
{ isDev: false },
);

const rootNode: PkgNode = {
id: ROOT_NODE_ID,
name: pkgJson.name,
version: pkgJson.version,
dependencies: topLevelDeps,
// Peers first so a real dependency for the same name takes precedence.
dependencies: { ...rootPeerDeps, ...topLevelDeps },
isDev: false,
inBundle: false,
key: '',
Expand Down Expand Up @@ -195,6 +205,12 @@ const dfsVisit = async (
pruneNpmStrictOutOfSync,
);

// A peer dependency that could not be resolved in the lockfile (e.g. an
// unmet or conflicting peer) is skipped rather than added to the graph.
if (!childNode) {
continue;
}

if (!visitedMap.has(childNode.id)) {
addPkgNodeToGraph(depGraphBuilder, childNode, {
showNpmScope,
Expand Down Expand Up @@ -236,6 +252,7 @@ const getChildNode = (
version: string;
isDev: boolean;
isOptional?: boolean;
isPeer?: boolean;
alias?: {
aliasName: string;
aliasTargetDepName: string;
Expand All @@ -251,7 +268,7 @@ const getChildNode = (
pkgKeysByName: Map<string, string[]>,
overrides?: Overrides,
pruneNpmStrictOutOfSync?: boolean,
) => {
): PkgNode | null => {
let version = depInfo.version;
let aliasInfo = depInfo.alias;

Expand Down Expand Up @@ -300,6 +317,13 @@ const getChildNode = (
);

if (!childNodeKey) {
// Peer dependencies are installed by npm v7+ and normally recorded in the
// lockfile, but an unmet or conflicting peer may have no entry. Skip it
// instead of erroring — a missing peer is not an out-of-sync lockfile.
if (depInfo.isPeer) {
return null;
}

// Handle optional dependencies that don't have separate package entries
if (depInfo.isOptional) {
return {
Expand Down Expand Up @@ -397,6 +421,16 @@ const getChildNode = (
})
: {};

// npm v7+ installs non-optional peer dependencies by default, so include a
// resolved package's peers (minus those flagged optional) in its subtree.
const peerDependencies = getPeerDependencies(
depData.peerDependencies,
depData.peerDependenciesMeta,
{
isDev: depInfo.isDev,
},
);

// Use the actual package name from the lockfile entry if it exists (for aliased packages),
// otherwise use the aliasInfo target name if available, otherwise use the dependency name
const actualPackageName =
Expand All @@ -407,6 +441,9 @@ const getChildNode = (
name: actualPackageName,
version: depData.version,
dependencies: {
// Peers first so a real dependency/optional/dev entry for the same
// package name takes precedence over the peer range.
...peerDependencies,
...dependencies,
...devDependencies,
...optionalDependencies,
Expand Down
1 change: 1 addition & 0 deletions lib/dep-graph-builders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type PackageJsonBase = {
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
resolutions?: Record<string, string>;
overrides?: Overrides;
pnpm?: {
Expand Down
44 changes: 43 additions & 1 deletion lib/dep-graph-builders/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getComponentMetadataLabels } from '../component-metadata-labels';

export type Dependencies = Record<
string,
{ version: string; isDev: boolean; isOptional?: boolean }
{ version: string; isDev: boolean; isOptional?: boolean; isPeer?: boolean }
>;

export interface PkgNode {
Expand Down Expand Up @@ -199,6 +199,48 @@ export const getGraphDependencies = (
);
};

/**
* Converts a package's peerDependencies into graph dependencies.
*
* From npm v7 onwards peer dependencies are installed by default and recorded
* in the lockfile (flagged `"peer": true`), so they are a real part of the
* resolved tree and must be scanned. The only peers npm does NOT auto-install
* are those explicitly marked optional in `peerDependenciesMeta`, so those are
* excluded here. Dependencies are flagged `isPeer` so the graph builder can
* tolerate a peer that is absent from the lockfile (e.g. an unmet/conflicting
* peer) by skipping it, rather than treating it as an out-of-sync lockfile.
*/
export const getPeerDependencies = (
peerDependencies: Record<string, string> | undefined,
peerDependenciesMeta: Record<string, { optional?: boolean }> | undefined,
options: {
isDev: boolean;
},
): Dependencies => {
if (!peerDependencies) {
return {};
}
return Object.entries(peerDependencies).reduce(
(peerDeps: Dependencies, [name, semver]) => {
// Skip invalid package names to prevent downstream errors
if (!isValidPackageName(name)) {
return peerDeps;
}
// Optional peer dependencies are not installed by npm v7+ by default.
if (peerDependenciesMeta?.[name]?.optional) {
return peerDeps;
}
peerDeps[name] = {
version: semver,
isDev: options.isDev,
isPeer: true,
};
return peerDeps;
},
{},
);
};

export function parsePkgJson(pkgJsonContent: string): PackageJsonBase {
const parsedPkgJson = parseJsonFile<PackageJsonBase>(
pkgJsonContent,
Expand Down
Loading