Repository navigation
[BUG] linked strategy: calcDepFlags produces incorrect dev flags due to multiple Links overwriting target #9100
Description
Is there an existing issue for this?
- I have searched the existing issues
This issue exists in the latest npm version
- I am using the latest npm
Current Behavior
When using install-strategy=linked, npm query reports incorrect dev flags on packages. The bug has two layers:
1. calcDepFlags: unconditional flag assignment overwrites correct values
When a target Node is reachable through multiple Links (common in linked strategy where each workspace has its own node_modules/ with Links), calcDepFlags unconditionally assigns target.dev = link.dev for each Link visited. The last Link processed wins — if it came through a dev path, it overwrites a previously correct dev=false.
2. loadActual: workspace edges resolve to null when workspaces are not in root node_modules/
In the linked strategy, only workspaces explicitly declared in root's dependencies/devDependencies get symlinks in root's node_modules/. Undeclared workspaces (those discovered only via the workspaces field) only get self-links in their own node_modules/. This means root's workspace edges resolve to null, calcDepFlags can't reach them, and ALL packages end up extraneous=true and dev=true.
3. edgesIn is always empty on store target nodes
In the linked strategy, edges resolve to Link nodes, not their targets. So from is always [] in npm query output.
These break any tooling that relies on npm query with .prod or .dev selectors.
See: WordPress/gutenberg#74429, WordPress/gutenberg#75039
Expected Behavior
npm query with linked strategy should produce the same dev flag classification as hoisted strategy.
Root Cause (calcDepFlags)
In calcDepFlags (arborist lib/calc-dep-flags.js), the Link handling unconditionally assigns flags:
if (node.isLink) {
node.target.dev = node.dev // ← can overwrite false back to true
node.target.optional = node.optional
// ...
queue.push(node.target)
}In the linked strategy, the same target node can have multiple Links from different locations:
node_modules/@test/tools→packages/tools/(root-level link, prod edge from root)packages/app/node_modules/@test/tools→packages/tools/(workspace-level link, dev edge from@test/app)
The second Link overwrites the correct dev=false with dev=true.
Fix: Only unset flags (true→false), never set them back — matching the pattern already used for edge processing:
if (node.target.dev && !node.dev) {
node.target.dev = false
changed = true
}Steps To Reproduce
mkdir test-linked && cd test-linked
mkdir -p packages/app packages/tools
cat > package.json << 'EOF'
{
"name": "test-linked",
"version": "1.0.0",
"workspaces": ["packages/*"]
}
EOF
cat > packages/app/package.json << 'EOF'
{
"name": "@test/app",
"version": "1.0.0",
"dependencies": { "abbrev": "^2.0.0" },
"devDependencies": { "@test/tools": "*" }
}
EOF
cat > packages/tools/package.json << 'EOF'
{
"name": "@test/tools",
"version": "1.0.0",
"dependencies": { "nopt": "^7.0.0" }
}
EOF
echo "install-strategy=linked" > .npmrc
npm install
npm query ':is(.prod)' | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
d.forEach(p => console.log(p.name, 'dev='+p.dev))
"Real-World Impact (Gutenberg)
Gutenberg uses install-strategy=linked in .npmrc. The license check script runs:
const query = `.workspace:attr([wpScript],[wpScriptModuleExports]) :is(.prod):not(...)`;
const child = spawn.sync("npm", ["query", query]);This incorrectly includes packages like @bufbuild/protobuf (Apache-2.0) and rxjs (Apache-2.0) which are transitive dependencies of sass-embedded — a dev-only build tool. These packages are flagged as GPL2-incompatible license violations, even though they are never distributed with WordPress.
The workaround was to rewrite the license checker to use Node's native module resolution instead of npm query (see PR #75039).
Environment
- npm: 11.11.0 and 10.9.5
- Node.js: v22.20.0
- OS Name: macOS (Darwin 25.3.0)
- npm config:
install-strategy=linked
Activity
manzoorwanijk commentedon Mar 10, 2026
@wraithgar there seems to be the same issue in the default hoisted strategy 🤔
wraithgar commentedon Mar 10, 2026
A clue here is that even without the
appworkspace created, thetoolsworkspace andabbrevandnoptshow up as.proddependencies.Workspaces are added to the tree even if they're not declared in the root package.json. So when another workspace eventually does require one as a
devDependency, it does so in an environment where that workspace has already been brought into the tree as a production dependency.One solution here is to specifically declare these workspaces as dev dependencies. Workspaces are always in your tree by design. Change their default to a
devDependencywould be a breaking change and not one to be taken lightly.Another approach is the one taken in this repo itself, and that is to declare all dependencies in these dev workspaces as dev dependencies. The
smoke-testsworkspace is a good example. That is not declared in the package.json but it does show up as a "production" dependency innpm query. However, because it is a workspace and not independenciesit is not included or looked for when npm itself is being installed.The concept of "dependency" vs "dev dependency" is really only to distinguish between which packages will be required when consuming a package as a registry package. When it is a local workspace these distinctions aren't "real". It's perfectly fine to set all dependencies in them as "dev" since they are not published packages.
wraithgar commentedon Mar 10, 2026
Example from npm itself:
fix(arborist): correct dev/prod dep flags for workspaces under the li…