Is there an existing issue for this?
This issue exists in the latest npm version
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:
Is there an existing issue for this?
This issue exists in the latest npm version
Current Behavior
When using
install-strategy=linked,npm queryreports incorrectdevflags on packages. The bug has two layers:1.
calcDepFlags: unconditional flag assignment overwrites correct valuesWhen a target Node is reachable through multiple Links (common in linked strategy where each workspace has its own
node_modules/with Links),calcDepFlagsunconditionally assignstarget.dev = link.devfor each Link visited. The last Link processed wins — if it came through a dev path, it overwrites a previously correctdev=false.2.
loadActual: workspace edges resolve tonullwhen workspaces are not in rootnode_modules/In the linked strategy, only workspaces explicitly declared in root's
dependencies/devDependenciesget symlinks in root'snode_modules/. Undeclared workspaces (those discovered only via theworkspacesfield) only get self-links in their ownnode_modules/. This means root's workspace edges resolve tonull,calcDepFlagscan't reach them, and ALL packages end upextraneous=trueanddev=true.3.
edgesInis always empty on store target nodesIn the linked strategy, edges resolve to Link nodes, not their targets. So
fromis always[]innpm queryoutput.These break any tooling that relies on
npm querywith.prodor.devselectors.See: WordPress/gutenberg#74429, WordPress/gutenberg#75039
Expected Behavior
npm querywith linked strategy should produce the samedevflag classification as hoisted strategy.Root Cause (calcDepFlags)
In
calcDepFlags(arboristlib/calc-dep-flags.js), the Link handling unconditionally assigns flags: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=falsewithdev=true.Fix: Only unset flags (true→false), never set them back — matching the pattern already used for edge processing:
Steps To Reproduce
Real-World Impact (Gutenberg)
Gutenberg uses
install-strategy=linkedin.npmrc. The license check script runs:This incorrectly includes packages like
@bufbuild/protobuf(Apache-2.0) andrxjs(Apache-2.0) which are transitive dependencies ofsass-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
install-strategy=linked