Skip to content

Commit c1921d0

Browse files
Merge pull request #311 from snyk/fix/osm-3760-yarn-workspace-dev-deps
fix(yarn-berry): prune workspace devDependencies from prod graph
2 parents 675f2cc + 8d05f54 commit c1921d0

10 files changed

Lines changed: 3743 additions & 4 deletions

File tree

lib/dep-graph-builders/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ export type YarnLockV2WorkspaceArgs = {
6262
isWorkspacePkg: boolean;
6363
isRoot: boolean;
6464
rootResolutions: Record<string, string>;
65+
// Map of workspace package name -> that member's package.json dependency groups.
66+
// Yarn Berry merges a workspace package's dependencies + devDependencies into a single
67+
// `dependencies` block in yarn.lock, dropping the dev marker. When a workspace package is
68+
// consumed as a production dependency we use this map to prune its dev-only dependencies
69+
// so they are not promoted into the production graph.
70+
workspacePackages?: Record<string, WorkspacePackageManifest>;
71+
};
72+
73+
export type WorkspacePackageManifest = {
74+
dependencies?: Record<string, string>;
75+
devDependencies?: Record<string, string>;
76+
optionalDependencies?: Record<string, string>;
77+
peerDependencies?: Record<string, string>;
6578
};
6679

6780
export type YarnLockV2ProjectParseOptions = {

lib/dep-graph-builders/yarn-lock-v2/build-depgraph-simple.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export const buildDepGraphYarnLockV2Simple = async (
5353
pruneWithinTopLevelDeps,
5454
undefined,
5555
showNpmScope,
56+
includeDevDeps,
57+
workspaceArgs?.workspacePackages,
5658
);
5759

5860
return depGraphBuilder.build();
@@ -74,6 +76,8 @@ const dfsVisit = async (
7476
pruneWithinTopLevel: boolean,
7577
visited?: Set<string>,
7678
showNpmScope?: boolean,
79+
includeDevDeps = false,
80+
workspacePackages?: YarnLockV2WorkspaceArgs['workspacePackages'],
7781
): Promise<void> => {
7882
for (const [name, depInfo] of Object.entries(node.dependencies || {})) {
7983
let scopeDepInfo = depInfo;
@@ -111,6 +115,8 @@ const dfsVisit = async (
111115
includeOptionalDeps,
112116
resolutions,
113117
node,
118+
includeDevDeps,
119+
workspacePackages,
114120
);
115121

116122
if (localVisited.has(childNode.id)) {
@@ -174,6 +180,8 @@ const dfsVisit = async (
174180
pruneWithinTopLevel,
175181
localVisited,
176182
showNpmScope,
183+
includeDevDeps,
184+
workspacePackages,
177185
);
178186
}
179187
};

lib/dep-graph-builders/yarn-lock-v2/utils.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { structUtils } from '@yarnpkg/core';
22
import * as _flatMap from 'lodash.flatmap';
33
import { OutOfSyncError } from '../../errors';
44
import { LockfileType } from '../../parsers';
5-
import { NormalisedPkgs } from '../types';
6-
import { getGraphDependencies, PkgNode } from '../util';
5+
import { NormalisedPkgs, WorkspacePackageManifest } from '../types';
6+
import { Dependencies, getGraphDependencies, PkgNode } from '../util';
77
import * as semver from 'semver';
88
import * as debugModule from 'debug';
99

@@ -99,6 +99,39 @@ export const yarnLockFileKeyNormalizer =
9999
return new Set<string>(_flatMap(allKeys));
100100
};
101101

102+
/**
103+
* Yarn Berry merges a workspace package's dependencies, devDependencies and peerDependencies
104+
* into a single `dependencies` block in yarn.lock, so the dev marker is lost. When such a
105+
* workspace package is consumed as a production dependency, walking that merged block would
106+
* promote its dev-only tooling (e.g. webpack, babel) into the production graph.
107+
*
108+
* Given the consumed workspace member's own package.json (via `workspaceManifest`), drop the
109+
* dev-only entries: names that appear in devDependencies but not in dependencies /
110+
* optionalDependencies / peerDependencies (prod wins on overlap).
111+
*/
112+
const pruneWorkspaceDevDependencies = (
113+
deps: Dependencies,
114+
workspaceManifest: WorkspacePackageManifest,
115+
): Dependencies => {
116+
const nonDev = new Set<string>([
117+
...Object.keys(workspaceManifest.dependencies || {}),
118+
...Object.keys(workspaceManifest.optionalDependencies || {}),
119+
...Object.keys(workspaceManifest.peerDependencies || {}),
120+
]);
121+
const dev = new Set<string>(
122+
Object.keys(workspaceManifest.devDependencies || {}),
123+
);
124+
125+
return Object.entries(deps).reduce((acc: Dependencies, [name, depInfo]) => {
126+
// Drop only names that are exclusively devDependencies of the workspace member.
127+
if (dev.has(name) && !nonDev.has(name)) {
128+
return acc;
129+
}
130+
acc[name] = depInfo;
131+
return acc;
132+
}, {});
133+
};
134+
102135
export const getYarnLockV2ChildNode = (
103136
name: string,
104137
depInfo: {
@@ -116,6 +149,8 @@ export const getYarnLockV2ChildNode = (
116149
includeOptionalDeps: boolean,
117150
resolutions: Record<string, string>,
118151
parentNode: PkgNode,
152+
includeDevDeps = false,
153+
workspacePackages?: Record<string, WorkspacePackageManifest>,
119154
) => {
120155
// First, check if a resolution would be used
121156
const resolvedVersionFromResolution = (() => {
@@ -256,7 +291,7 @@ export const getYarnLockV2ChildNode = (
256291
optionalDependencies,
257292
} = pkgData;
258293

259-
const formattedDependencies = getGraphDependencies(dependencies || {}, {
294+
let formattedDependencies = getGraphDependencies(dependencies || {}, {
260295
isDev: depInfo.isDev,
261296
});
262297
const formattedOptionalDependencies = includeOptionalDeps
@@ -266,6 +301,23 @@ export const getYarnLockV2ChildNode = (
266301
})
267302
: {};
268303

304+
// Key by the resolved package name, not the parent's name, so npm aliases
305+
// (e.g. "alias": "npm:@scope/real-pkg@1") still match the workspace manifest.
306+
const workspaceManifestFromResolution =
307+
workspacePackages?.[
308+
depInfo.alias ? depInfo.alias.aliasTargetDepName : name
309+
];
310+
if (
311+
!includeDevDeps &&
312+
workspaceManifestFromResolution &&
313+
pkgData.resolution?.includes('@workspace:')
314+
) {
315+
formattedDependencies = pruneWorkspaceDevDependencies(
316+
formattedDependencies,
317+
workspaceManifestFromResolution,
318+
);
319+
}
320+
269321
return {
270322
id: `${name}@${versionFromResolution}`,
271323
name: depInfo.alias ? depInfo.alias.aliasTargetDepName : name,
@@ -301,7 +353,7 @@ export const getYarnLockV2ChildNode = (
301353
}
302354
} else {
303355
const depData = pkgs[childNodeKey];
304-
const dependencies = getGraphDependencies(depData.dependencies || {}, {
356+
let dependencies = getGraphDependencies(depData.dependencies || {}, {
305357
isDev: depInfo.isDev,
306358
});
307359
const optionalDependencies = includeOptionalDeps
@@ -310,6 +362,24 @@ export const getYarnLockV2ChildNode = (
310362
isOptional: true,
311363
})
312364
: {};
365+
366+
// Key by the resolved package name, not the parent's name, so npm aliases
367+
// (e.g. "alias": "npm:@scope/real-pkg@1") still match the workspace manifest.
368+
const workspaceManifest =
369+
workspacePackages?.[
370+
depInfo.alias ? depInfo.alias.aliasTargetDepName : name
371+
];
372+
if (
373+
!includeDevDeps &&
374+
workspaceManifest &&
375+
depData.resolution?.includes('@workspace:')
376+
) {
377+
dependencies = pruneWorkspaceDevDependencies(
378+
dependencies,
379+
workspaceManifest,
380+
);
381+
}
382+
313383
return {
314384
id: `${name}@${depData.version}`,
315385
name: depInfo.alias ? depInfo.alias.aliasTargetDepName : name,

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import type {
6262
NormalisedPkgs,
6363
ProjectParseOptions,
6464
YarnLockV2ProjectParseOptions,
65+
WorkspacePackageManifest,
6566
} from './dep-graph-builders/types';
6667
import {
6768
getLockfileVersionFromFile,
@@ -92,6 +93,7 @@ export {
9293
PackageJsonBase,
9394
ProjectParseOptions,
9495
YarnLockV2ProjectParseOptions,
96+
WorkspacePackageManifest,
9597
NormalisedPkgs,
9698
NormalisedPkgs as YarnLockPackages,
9799
getLockfileVersionFromFile,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@demo/my-app",
3+
"version": "0.0.0-development",
4+
"author": "Demo Team",
5+
"files": [
6+
"dist",
7+
"src"
8+
],
9+
"main": "./src/index.js",
10+
"scripts": {
11+
"build": "webpack --config webpack.config.js",
12+
"test": "jest",
13+
"package:clean": "CLEAN_PACKAGE=$(jq --unbuffered 'setpath([\"devDependencies\"]; .devDependencies * (.dependencies // {})) | del(.dependencies)' < ./package.json); printf '%s' \"$CLEAN_PACKAGE\" > ./package.json"
14+
},
15+
"devDependencies": {
16+
"@demo/private-lib": "workspace:^",
17+
"eslint": "^8",
18+
"webpack": "^5.90.0",
19+
"webpack-cli": "^5.1.4"
20+
},
21+
"dependencies": {
22+
"@demo/shared-lib": "*"
23+
}
24+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@demo/private-lib",
3+
"version": "0.0.1",
4+
"private": true,
5+
"author": "Demo Team",
6+
"files": [
7+
"src",
8+
"dist"
9+
],
10+
"main": "./dist/index.js",
11+
"scripts": {
12+
"build": "webpack --config webpack.config.js",
13+
"test": "jest"
14+
},
15+
"dependencies": {
16+
"semver": "^7.6.0"
17+
},
18+
"devDependencies": {
19+
"@babel/core": "^7.24.0",
20+
"@babel/preset-env": "^7.24.0",
21+
"babel-loader": "^9.1.3",
22+
"webpack": "^5.90.0",
23+
"webpack-cli": "^5.1.4"
24+
}
25+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@demo/shared-lib",
3+
"version": "0.0.1",
4+
"author": "Demo Team",
5+
"files": [
6+
"src",
7+
"dist"
8+
],
9+
"main": "./dist/index.js",
10+
"scripts": {
11+
"build": "webpack --config webpack.config.js",
12+
"test": "jest"
13+
},
14+
"devDependencies": {
15+
"@babel/core": "^7.24.0",
16+
"@babel/preset-env": "^7.24.0",
17+
"babel-loader": "^9.1.3",
18+
"webpack": "^5.90.0",
19+
"webpack-cli": "^5.1.4"
20+
}
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@demo/workspace-root",
3+
"packageManager": "yarn@4.3.0",
4+
"version": "0.0.0-development",
5+
"private": true,
6+
"workspaces": [
7+
"apps/*",
8+
"libraries/*"
9+
],
10+
"scripts": {
11+
"build:all": "yarn workspaces foreach -Avp --topological-dev run build",
12+
"test:all": "yarn workspaces foreach -Avp --topological-dev run test"
13+
},
14+
"devDependencies": {
15+
"eslint": "^8",
16+
"typescript": "^5"
17+
},
18+
"resolutions": {
19+
"webpack": "^5.90.0"
20+
}
21+
}

0 commit comments

Comments
 (0)