Skip to content

fix(npm): hoisted transitive packages are marked as direct #74

Description

@tgolob

Problem

For lockfile v2/v3, the npm scanner currently treats every package whose key contains exactly one node_modules/ segment as direct:

func isDirectFromKey(key string) bool {
    return strings.Count(key, "node_modules/") == 1
}

That describes the package's physical install depth, not whether the root project declared it. npm hoists transitive dependencies to the top-level node_modules whenever it can, so a transitive package commonly has a key such as node_modules/is-number and is incorrectly emitted with direct_dependency=true.

Reproduction

With npm 11.19.0:

npm init -y
npm install --package-lock-only --ignore-scripts is-odd@3.0.1
bumblebee scan --profile project --root "$PWD" --ecosystem npm

The generated lockfile has:

{
  "packages": {
    "": { "dependencies": { "is-odd": "^3.0.1" } },
    "node_modules/is-number": { "version": "6.0.0" },
    "node_modules/is-odd": { "version": "3.0.1" }
  }
}

is-odd declares is-number; the root project does not. Current Bumblebee output nevertheless reports:

{"package_name":"is-number","version":"6.0.0","direct_dependency":true}
{"package_name":"is-odd","version":"3.0.1","direct_dependency":true}

This can overstate direct exposure across ordinary npm projects.

Expected behavior

For a normal package lock, directness should come from the root package descriptor at packages[""] (dependencies, devDependencies, optionalDependencies, and peerDependencies), together with a top-level package key. Physical hoisting alone should not make a package direct.

For npm's hidden node_modules/.package-lock.json, which normally omits the root project descriptor, direct_dependency should remain absent/unknown rather than being inferred from install depth.

The npm documentation describes packages as a location map and notes that the root project normally uses the empty-string key: https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json#packages. npm's default installation strategy hoists dependencies to the highest possible level, so location depth is not dependency-graph depth.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions