Because npm install has the insane default behavior of adding a fuzzy qualifier to your package.json, for example ^6.0.2 means all of the following versions are accepted: 6.0.2, 6.0.9, 6.7.84
It’s not particularly insane. package.json and package-lock.json have different purposes, namely package.json specified intent e.g. I want a version that satisfies >=5.2.3 && < 6.0.0 and package-lock.json records the exact resolved version.
Off the top of my head Bundler, CocoaPods, Cargo, SPM, Pipfile(and various other Python dependency managers), and composer also all work like this.
Cargo even makes it implicit that a version like “1” means “^1.0.0” in Cargo.toml.
Very often, package installation is automated as part of a build pipeline. So if you want to build and deploy a new version of your software, you'll kick off the pipeline and that could potentially download a newer version of a package than was previously being used.
Incidents like this highlight that this may not be the best idea.
If you're using NPM without lockfiles, you're gonna have a bad time with discrepancies between trying things on your dev machine and building things in CI machines.
When you have a package-lock.json NPM will install exactly the same version of everything in your dependency tree, making the CI builds much more like what's on your dev machine (modulo architecture/environment changes)
Because of version locks. Normally you install “^X.Y.Z” which means any version at major X with at least minor Y and revision Z. For more conservative codebases you install “~X.Y.Z” which also locks the minor.
npm install will traditionally install the most recent packages that match your constraints. You need “npm ci” to use true version locks
The first line of NPM install's documentation[0] says(emphasis mine):
> This command installs a package, and any packages that it depends on. If the package has a *package-lock or shrinkwrap file, the installation of dependencies will be driven by that*, with an npm-shrinkwrap.json taking precedence if both files exist. See package-lock.json and npm shrinkwrap.
What does happen is: if you have added a new package in package.json it will be installed based on the semver pattern specified there, or if you run npm install some-package@^x.y.z the same thing happens. Further, if you modify package.json by changing the semver pattern for an existing package that will also cause this behaviour.
Running `npm install` in a package that already has a package-lock.json will simply install what's in package-lock.json. `npm install` only changes the lock file to add/remove/update dependencies when it detects that package.json and package-lock.json disagrees about the specified dependenices and their semver patterns e.g. having foo@^2.3.1 in package.json and foo@1.8.3 in package-lock.json will cause foo to be update when running `npm install`.