Skip to content

fix(yarn-berry): prune workspace devDependencies from prod graph#311

Open
andreipopa-who wants to merge 2 commits into
mainfrom
fix/osm-3760-yarn-workspace-dev-deps
Open

fix(yarn-berry): prune workspace devDependencies from prod graph#311
andreipopa-who wants to merge 2 commits into
mainfrom
fix/osm-3760-yarn-workspace-dev-deps

Conversation

@andreipopa-who

@andreipopa-who andreipopa-who commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Yarn Berry merges a workspace package's dependencies and devDependencies into a single dependencies block in yarn.lock, dropping the dev marker. When a workspace package is consumed as a production dependency, the yarn-lock-v2 builder walked that whole block and inherited the parent's prod scope, promoting the consumed package's dev-only tooling (webpack, babel, ...) into the production graph as false positives.

Give the builder the consumed member's own package.json dependency groups via the new optional YarnLockV2WorkspaceArgs.workspacePackages map. When a child resolves to a @workspace: node and includeDevDeps is false, drop the dev-only entries (names in devDependencies but not in dependencies/optional/peer; prod wins on overlap).

Backward-compatible: with no workspacePackages provided, behavior is unchanged.

What this does

Fixes false positives in Yarn Berry (v2/3/4) workspaces, where a consumed workspace package's dev-only build tooling (webpack, babel, ...) was reported as production dependencies of the consumer. Root cause: yarn.lock flattens a workspace member's dependencies + devDependencies into a single dependencies block with no dev marker, and getYarnLockV2ChildNode walked the whole block while inheriting the parent's prod scope (no includeDevDeps gate). The npm and pnpm parsers are unaffected because those lockfiles keep dev deps separate — so the fix belongs in the yarn-berry builder.

  • types.ts: add optional workspacePackages to YarnLockV2WorkspaceArgs (+ WorkspacePackageManifest).
  • yarn-lock-v2/utils.ts: new pruneWorkspaceDevDependencies helper; getYarnLockV2ChildNode prunes dev-only deps when the child is a @workspace: node and includeDevDeps is false — in both the resolution and the normal branch.
  • yarn-lock-v2/build-depgraph-simple.ts: thread includeDevDeps + workspacePackages through dfsVisit.

Notes for the reviewer

  • Detection uses the preserved resolution field (...@workspace:) on NormalisedPkgs, so no extract change is needed. Pruning drops names in the member's devDependencies that are not also in dependencies/optionalDependencies/peerDependencies (prod wins on overlap).
  • Run locally: npm run test:jest. New regression tests live in test/jest/dep-graph-builders/yarn-lock-v2.test.ts (fixture test/jest/dep-graph-builders/fixtures/yarn-lock-v2/real/workspace-dev-deps/) and assert the leak without the map, the fix with it, and that includeDevDeps: true is unaffected. Full suite: 358/358.
  • Consumer: snyk-nodejs-plugin#48 populates workspacePackages from each member's package.json. This PR is backward-compatible, so it can land and release ahead of the plugin bump.
  • Edge cases covered: npm:-aliased workspace keys (@demo/shared-lib@npm:*) key off the package name; nested workspace→workspace hops prune at each boundary during DFS recursion.
  • Large diff: the fixture yarn.lock is a real generated Yarn 4 lockfile (~3.3k lines); the source change is ~70 lines.

More information

Screenshots

Parsing apps/my-app (whose only prod dep is the workspace package @demo/shared-lib, which itself has only devDependencies) against the root yarn.lock, includeDevDeps: false:

prod deps in my-app graph dev tooling present
before 242 webpack, webpack-cli, @babel/core, @babel/preset-env, babel-loader
after 1 (@demo/shared-lib) none
before                              after
my-app                              my-app
└─ @demo/shared-lib  (prod)         └─ @demo/shared-lib   (prod, leaf)
   ├─ webpack         ✗ dev
   ├─ @babel/core     ✗ dev
   ├─ babel-loader    ✗ dev
   └─ … 238 more      ✗ dev

@CLAassistant

CLAassistant commented Jun 8, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

})
: {};

const workspaceManifestFromResolution = workspacePackages?.[name];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if wrong but here we key by name but if a customer uses an alias like

"some-package": "npm:@alias/other-name@1"

Then if we pass in the alias to getYarnLockV2ChildNode as we pass in whatever the parents know the pkg as. Then we will be returning undefined for something we have an answer too.

Should we be respecting the alias here or am I overthinking it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch ! 🦾

This commit should solve the problem 👍
8d05f54

})
: {};

const workspaceManifest = workspacePackages?.[name];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above comment

Yarn Berry merges a workspace package's dependencies and devDependencies into a
single `dependencies` block in yarn.lock, dropping the dev marker. When a
workspace package is consumed as a production dependency, the yarn-lock-v2
builder walked that whole block and inherited the parent's prod scope, promoting
the consumed package's dev-only tooling (webpack, babel, ...) into the
production graph as false positives.

Give the builder the consumed member's own package.json dependency groups via
the new optional `YarnLockV2WorkspaceArgs.workspacePackages` map. When a child
resolves to a `@workspace:` node and `includeDevDeps` is false, drop the
dev-only entries (names in devDependencies but not in
dependencies/optional/peer; prod wins on overlap). `WorkspacePackageManifest` is
exported from the package entry so consumers can build the map type-safely.

Backward-compatible: with no `workspacePackages` provided, behavior is
unchanged.

Tests: 358/358 pass (incl. regression tests built from a workspace fixture).
@andreipopa-who andreipopa-who force-pushed the fix/osm-3760-yarn-workspace-dev-deps branch from 5131405 to 08a9545 Compare June 9, 2026 09:11
The workspace dev-dependency prune looked up the consumed member's manifest by
the parent's name for the dependency. For an npm alias (e.g.
"alias": "npm:@scope/real-pkg@1") that name is the alias, not the package's real
name, so the lookup missed the workspacePackages entry (keyed by the real name)
and pruning was skipped.

Key both lookups by the resolved name (depInfo.alias.aliasTargetDepName when
aliased) — the same value the child node's `name` field already uses, and add a
unit test covering an aliased workspace package.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants