Das Symptom
Ein Paket soll in genau einer Version ins Projekt, also wird sie beim Installieren ausdrücklich angegeben, Paketname, @, Versionsnummer. Damit ist die Sache erledigt, denkt man. Monate später baut jemand das Projekt frisch, ohne Lockfile oder mit einem neu erzeugten, und bekommt eine neuere Minor-Version mit leicht anderem Verhalten. Die Fehlersuche führt in die package.json, und dort steht vor der Versionsnummer ein Zirkumflex. Der Entwickler ist sicher, das nie geschrieben zu haben.
Er hat recht. Geschrieben hat es npm, beim Speichern, ohne Rückfrage, nach Voreinstellung.
Die „Layer 8“-Wahrheit
npm unterscheidet zwischen dem, was installiert wird, und dem, was in der package.json gespeichert wird. Installiert wird bei einer Versionsangabe genau diese Version; die Dokumentation von npm install sagt dazu schlicht „Install the specified version of the package.“ Gespeichert wird aber nicht die Version, sondern ein Bereich. Zuständig ist die Einstellung save-prefix, deren Standardwert das Zirkumflex ist, und die Dokumentation beschreibt genau diesen Fall am Beispiel einer Version 1.2.3: „by default its version is set to ^1.2.3 which allows minor upgrades for that package“. Bei der Prüfung für diesen Eintrag mit npm 10.9.7 ergab die Installation einer genauen Version prompt einen Eintrag mit ^ davor.
Der Denkfehler liegt in der Annahme, die Versionsangabe im Befehl sei eine Anweisung an die package.json. Sie ist nur eine Anweisung an die Installation. Wer eine exakte Version gespeichert haben will, muss das getrennt sagen, mit –save-exact: „Saved dependencies will be configured with an exact version rather than using npm’s default semver range operator.“ Die Formulierung verrät, wer hier den Standard setzt, nämlich npm und nicht der Entwickler.
Gemildert wird das Problem durch das Lockfile. Solange package-lock.json eingecheckt ist und seine Versionen die Bereiche der package.json erfüllen, nimmt npm laut Dokumentation die exakten Versionen aus dem Lockfile. Der Bereich beißt erst, wenn das Lockfile fehlt, neu erzeugt wird oder gar nicht gilt, etwa bei den Nutzern eines veröffentlichten Pakets, denn package-lock.json wird laut npm außerhalb des Wurzelprojekts ignoriert. Dass dann neuere Minor-Versionen kommen, ist kein Fehler von npm, sondern genau das, was der Bereich erlaubt. Ob diese Versionen kompatibel sind, hängt allein daran, ob ihre Autoren sich an die Empfehlungen zur semantischen Versionierung halten.
Die Lösung ist deshalb keine Reparatur, sondern eine Entscheidung. Entweder Bereiche, dann bewusst, mit eingechecktem Lockfile und npm ci im Build. Oder exakte Einträge, dann projektweit über save-exact in der .npmrc, damit nicht jeder einzelne Befehl daran denken muss.