Skip to content

Audit: Pinning indirect npm dependencies

Who this applies to

Every project with a package.json at the root of the repository, and which declares at least one dependency in it. Everything else is not applicable, which today is every repository in the fleet except hunkydory.

A project whose lockfile is yarn.lock, pnpm-lock.yaml or bun.lockb rather than an npm one is reported not applicable with that reason. Those files pin a tree too, and reading them is work nobody in the fleet needs yet; the criterion says so rather than failing a project for using a package manager it does not parse.

npm-shrinkwrap.json is not one of those. It is npm's own format -- the same schema as package-lock.json, installed exactly by npm ci -- and it is the publishable spelling, which npm reads in preference when a project has both. A project pinning its tree that way is doing what this criterion asks, so it is read as the lockfile rather than reported as a package manager we do not parse.

What we check

  • package-lock.json, or npm-shrinkwrap.json, exists.
  • It is committed. A lockfile that only ever existed in the working copy that generated it pins nothing for anybody else.
  • Its lockfileVersion is 2 or later.
  • No workflow installs the project's dependencies with npm install.

Why this is shaped differently to the Python criterion

pin-indirect-dependencies.md asks a Python project to run a reconciler, keep a generated block of transitive pins in pyproject.toml, and open a pull request when that block changes. None of that is needed here, because npm already does it.

package-lock.json records every package in the transitive closure with the exact version it resolved to and an integrity hash for the artefact, and npm ci installs precisely that tree -- failing rather than resolving if the lockfile and the manifest disagree. The pinning story is satisfied by construction, so asking for a reconciler on top of it would be inventing a rule npm does not have.

What is not satisfied by construction is that the mechanism is present and honoured. That is what the four points above measure, and each of them is a way a project can have a lockfile and still install a tree nobody chose.

A lockfile that was never committed. .gitignore entries for package-lock.json are common advice for libraries, on the reasoning that a consumer resolves their own tree. That reasoning does not reach CI, which resolves a fresh tree on every run and therefore tests a different set of packages to the one the last run tested.

lockfileVersion 1. Written by npm 6 and before. It predates the packages map, and records the tree in a nested form that npm 7 and later rewrite on first contact. A v1 file in the tree is a lockfile that the next npm install will replace, so the pins in it are a snapshot rather than a contract.

npm install in CI. This is the one that undoes the rest. npm ci installs the lockfile and refuses to modify it; npm install resolves the ranges in package.json afresh, writes any newer versions it finds back into the lockfile, and carries on. A workflow doing that tests a tree that nothing pinned and nobody reviewed, which is the exact failure this criterion exists to prevent, reached from the other direction.

npm install -g is not that. A global install puts a tool on the runner's path beside the project -- the way several workflows in the fleet install the Claude Code CLI -- and touches no lockfile, so it is not reported.

Neither is npm install --package-lock-only, which resolves and rewrites the lockfile without installing anything. That is how a lockfile is deliberately refreshed, and nothing is tested against the tree it produces until the resulting lockfile is reviewed and merged.

Neither, finally, is the phrase appearing somewhere that is not a command. Only the body of a run: is read, and the command has to start there or follow a |, && or ;. A step called "never use npm install here", an echo saying the same thing, and a comment at the end of a npm ci line are a workflow describing the mistake rather than making it, and failing a compliant repository for saying so is the expensive direction to be wrong in.

What we deliberately do not check

Whether the lockfile is in step with package.json. Answering that means resolving the declared ranges, which means reaching the registry, and the audit reads checkouts offline. npm ci already fails loudly on exactly that condition, so the answer arrives from CI on the next run rather than from here.

Projects

Per-project compliance for this criterion is regenerated every morning by the consistency audit: see the compliance page.

📝 Report an issue with this page