#9996·npm

npm ls marks satisfied edges invalid when sibling override rulesets disagree about an unrelated, not-installed package

Author: BizaNatorCreated Sep 17, 2026Updated Sep 17, 2026

Summary

npm ls --all reports a dependency edge as invalid even though the target version satisfies the edge's range, when:

  • the edge's override set is the rule for a root-level overridden package (package A),
  • the target node's override set is the rule for a different root-level scoped override (package B),
  • A's rule and B's rule are siblings under the root override set, and
  • the collapsed rulesets of the two siblings disagree about the version of some third package — one side sees the root's global override value, the other side sees a scoped override value that shadows it.

In the repro below the third package (js-yaml) is not installed anywhere in the tree, and no override rule references either the edge's from or to package (http-proxy-agentdebug).

Environment

  • Node v22.22.0, Linux
  • Affected: npm 11.2.0 → 11.19.1 (arborist 9.0.1 → 9.9.1) and npm 12.0.2 (arborist 10.0.2)
  • Not affected: npm 11.1.0 (arborist 9.0.0) and npm 10.9.9
  • Introduced by arborist 9.0.1 (shipped in npm 11.2.0) via #8089 (commit b9225e52), which added the doOverrideSetsConflict INVALID branch to Edge#get error()

Reproduction

package.json:

json
{
  "name": "override-ruleset-repro",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "http-proxy-agent": "^7.0.0",
    "@babel/core": "^7.29.7"
  },
  "overrides": {
    "js-yaml": "5.3.0",
    "@babel/core": "^7.29.7",
    "http-proxy-agent": {
      "js-yaml": "4.3.2"
    }
  }
}

Build the lockfile with an unaffected npm, then re-install and list with a current npm:

bash
$ [email protected] install
$ [email protected] ci
$ [email protected] ls --all
...
│ ├─┬ [email protected] invalid: "^4.3.4" from node_modules/http-proxy-agent
│ │ └── [email protected]
...
npm error code ELSPROBLEMS
npm error invalid: [email protected] .../node_modules/debug

[email protected] satisfies ^4.3.4. Running [email protected] ls --all against the same node_modules and lockfile exits 0 with no invalid edges — only the npm version differs.

Why it happens

With npm 11.19.1's bundled arborist (9.9.1):

  • workspaces/arborist/lib/edge.js line 278 marks the edge INVALID when doOverrideSetsConflict(edge.overrides, target.overrides) returns true.
  • The edge from http-proxy-agent carries the override set http-proxy-agent=* > ROOT; the hoisted debug node carries @babel/core=^7.29.7 > ROOT (assigned from the @babel/core placement). These rulesets are siblings, so findSpecificOverrideSet() finds no containment in either direction and falls through to haveConflictingRules().
  • haveConflictingRules() compares first.ruleset vs second.ruleset. The ruleset getter collapses the whole ancestry (self first, so a scoped child shadows the root value for the same key):
    • @babel/core's collapsed set contains js-yaml -> 5.3.0 (the root global override),
    • http-proxy-agent's collapsed set contains js-yaml -> 4.3.2 (its own scoped rule shadows the root's),
    • semver.intersects('5.3.0', '4.3.2') is false → conflict declared → edge INVALID.

Neither ruleset contains a rule for debug, and the two js-yaml rules can never apply to the same package instance (one is scoped to http-proxy-agent's subtree), so this is a false positive for the http-proxy-agent → debug edge specifically.

Programmatic probe (arborist 9.9.1, loadActual() on the repro):

[email protected]:
  node.ov chain : @babel/core=^7.29.7 > ROOT
  node.ov rules : @babel/core->^7.29.7, http-proxy-agent->*, js-yaml->5.3.0
  js-yaml installed anywhere: NO

edge ^4.1.0 from @babel/core:      edge.ov=@babel/core=^7.29.7 > ROOT  -> OK
edge ^4.3.1 from @babel/traverse:  edge.ov=@babel/core=^7.29.7 > ROOT  -> OK
edge ^4.3.4 from http-proxy-agent: edge.ov=http-proxy-agent=* > ROOT   -> INVALID

Workarounds

Removing either the global js-yaml override or the scoped http-proxy-agent → js-yaml override makes the edge valid again — but both exist for real reasons in the original project (global security pin + a scoped legacy pin). Our CI currently has to tolerate non-zero npm ls --all exit codes.

Suggested direction

haveConflictingRules (or findSpecificOverrideSet) could restrict the comparison to rules that can actually apply to the edge's target — for example only declaring a conflict when a rule for the target package's name (or for a package in its dependency closure) differs between the two sets. Comparing the full collapsed rulesets of sibling subtrees compares rules that are mutually exclusive by construction.

Related

  • #7087 — invalid edges with overrides (different trigger)
  • #9514 — overrides within workspaces
  • #4834 — overrides not applied with workspaces

This report differs in that no workspaces are involved and the "conflicting" override key refers to a package that is not installed at all.