A range describes what you will accept; a lockfile records what you actually got. package-lock.json stores one entry per installed path, and the interesting field is integrity — a Subresource Integrity hash of the tarball. npm 2,036 recomputes it on every later install and refuses the package if the bytes differ, so a registry serving something new under an old version is caught, not trusted.
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEF...",
"license": "ISC"
}npm install treats the lockfile as a starting point and rewrites it when the manifest has moved. npm ci does the opposite: it requires a lockfile, deletes node_modules, installs exactly what the lockfile says, never writes it back, and exits non-zero if lockfile and manifest disagree. That failure is the feature. Use npm ci in every CI job, Docker 514 build and deployment; use npm install only when you intend to change dependencies.
Commit the lockfile for libraries too: it is not shipped in the tarball and never affects consumers, but it does pin the dev toolchain your own CI runs — exactly what you want when a linter ships a bad patch.
Commit exactly one lockfile per repository: two package managers writing two lockfiles guarantees that production and your laptop run different code.