Cos'è il lockfile e perché non va mai ignorato
Cosa contiene davvero un lockfile, perché package.json da solo non basta, npm ci contro npm install, come si risolvono i conflitti e quando va committato.
È il file più lungo del repository, nessuno lo legge mai, e quando genera un conflitto in una pull request la tentazione è risolverlo a mano riga per riga. Poi la build passa in locale e fallisce in CI, oppure il contrario, e nessuno capisce perché. In questo articolo trovi cosa c'è davvero dentro un lockfile, perché il package.json da solo non è sufficiente, come si risolvono i conflitti senza impazzire e l'unico caso in cui davvero non va committato.
Cos'è
Il lockfile è la fotografia esatta dell'albero delle dipendenze installato: ogni pacchetto, diretto o indiretto, con la sua versione precisa e l'impronta del contenuto scaricato.
Che si chiami package-lock.json, yarn.lock o pnpm-lock.yaml cambia il formato, non il ruolo. Lo genera il gestore, non lo scrivi tu, e serve a una cosa sola: far sì che la stessa installazione, fatta da chiunque e in qualunque momento, produca esattamente lo stesso node_modules.
Il confronto tra i tre gestori e i loro formati è in npm, yarn o pnpm. Qui il tema è il file in sé.
Cosa contiene davvero
Aprine uno e vedrai migliaia di righe che sembrano tutte uguali. In realtà ogni voce registra quattro cose.
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
}
- La versione esatta. Non un intervallo:
2.1.3, punto. - Da dove è stata scaricata. L'URL preciso, utile se usi un registro aziendale.
- L'hash di integrità. L'impronta crittografica del file scaricato.
- La posizione nell'albero. Quella chiave
node_modules/msdice dove il pacchetto finisce fisicamente, perché lo stesso pacchetto può esistere in due versioni in punti diversi dell'albero.
La parte importante è che ci sono tutte le dipendenze, non solo le tue. Nel package.json hai scritto cinque righe; nel lock ce ne sono ottocento, perché ogni tua dipendenza ne porta altre, che ne portano altre ancora. Sono le dipendenze transitive, ed è lì che vive la quasi totalità del codice che eseguirai.
Il lock registra anche come i conflitti sono stati risolti. Se due pacchetti chiedono versioni diverse della stessa libreria, il gestore decide: una al primo livello, l'altra annidata sotto chi la richiede. Senza il lock quella decisione verrebbe presa di nuovo ogni volta, potenzialmente in modo diverso.
Perché il package.json non basta
Nel package.json scrivi intervalli, non versioni:
"dependencies": {
"express": "^4.18.0"
}
Quel ^ significa "la 4.18.0 o qualunque aggiornamento compatibile". Il che, tradotto: la versione che ottieni dipende dal giorno in cui installi.
Uno scenario che non è teorico:
- Lunedì tu esegui
npm install. Express è alla 4.18.2. Tutto funziona. - Tre settimane dopo un collega clona il repository e installa. Nel frattempo è uscita la 4.19.0, e nell'albero è cambiata anche una dipendenza transitiva di Express, magari due livelli più giù.
- Stesso codice, alberi diversi. Da lui un comportamento si rompe. Da te no. Nessuno dei due ha toccato nulla.
Il dettaglio cattivo è che il cambiamento quasi mai è nelle tue dipendenze dirette. È in una libreria che non hai mai sentito nominare, tirata dentro da un'altra che a sua volta è stata tirata dentro da quella che hai installato tu. Nel package.json non c'è.
Il lockfile chiude la questione: con esso, l'installazione di tre settimane dopo produce esattamente l'albero di lunedì. È la differenza pratica tra un progetto riproducibile e il classico "sulla mia macchina funziona".
È lo stesso ragionamento che sta dietro alle immagini Docker: l'ambiente di esecuzione deve essere un fatto, non una coincidenza.
npm ci contro npm install
Due comandi che sembrano simili e fanno cose profondamente diverse.
npm install | npm ci | |
|---|---|---|
| Legge | package.json, poi il lock | solo il lock |
| Modifica il lock | sì, se serve | mai |
| node_modules esistente | aggiorna sul posto | lo cancella e ricrea |
| Se lock e package.json divergono | risolve e riscrive | fallisce con errore |
| Serve il lock | no | sì, obbligatorio |
| Velocità | media | più alta |
npm install ragiona: confronta ciò che chiedi con ciò che c'è, risolve le differenze, aggiorna il lock. È quello che vuoi in locale quando cambi una dipendenza.
npm ci non ragiona: prende il lock e lo materializza, senza guardare gli intervalli. È più veloce proprio perché salta tutta la fase di risoluzione, e cancellando node_modules parte sempre da zero.
In CI si usa sempre ci, per due motivi che vale la pena separare. Il primo è la velocità, ed è quello di cui si parla. Il secondo è più importante: se qualcuno ha modificato il package.json senza rigenerare il lock, npm ci fallisce invece di aggiustare silenziosamente. Quella divergenza è un errore, e la pipeline è il posto giusto dove farla emergere. Gli equivalenti sono yarn install --immutable e pnpm install --frozen-lockfile.
# In un workflow di GitHub Actions
- run: npm ci
- run: npm run build
- run: npm test
Lo stesso vale nel Dockerfile: COPY package*.json ./ seguito da RUN npm ci ti dà build riproducibili e una cache dei livelli che funziona, perché il livello delle dipendenze si invalida solo quando cambiano le dipendenze.
I conflitti nelle pull request
Succede sempre: due persone aggiungono una dipendenza sullo stesso branch di partenza, e Git ti mostra un conflitto lungo trecento righe di JSON.
La cosa da non fare è risolverlo a mano. Il lockfile è un artefatto generato, e un albero risolto a metà da te e a metà dal collega non corrisponde a nessuna installazione reale. Anche se il JSON resta valido — e spesso lo resta — il risultato è incoerente in modi che scopri settimane dopo.
Si rigenera. Il procedimento corretto è sempre questo:
git checkout main -- package-lock.json # prendi la versione della base
npm install # rigenera con il tuo package.json
git add package-lock.json
Il passaggio chiave è il primo: parti da un lock coerente e lasci che il gestore riapplichi sopra ciò che il tuo package.json chiede. Se anche il package.json è in conflitto, risolvi prima quello — lì sì a mano, perché l'hai scritto tu — e poi rigenera il lock.
Una nota: npm install da solo basta in quasi tutti i casi, perché npm sa ricostruire l'albero anche partendo da un lock in conflitto. Se il risultato ti sembra strano, cancella node_modules e il lock e reinstalla da zero.
Se i conflitti sul lock sono frequenti nel tuo team, il problema non è il lock: sono i branch che restano aperti troppo a lungo.
Quando va committato
Sempre, per le applicazioni. Siti, API, strumenti interni, script: qualunque cosa venga eseguita invece che installata come dipendenza. Senza lock la tua build non è riproducibile, e il giorno in cui devi ricostruire una versione di sei mesi fa per capire una regressione, scopri che non puoi.
L'unica eccezione reale sono le librerie pubblicate. E il motivo è preciso: quando qualcuno installa la tua libreria, il tuo lockfile viene ignorato. Non ha alcun effetto su di lui. Il gestore di chi ti installa legge le tue dependencies — gli intervalli — e le risolve nel suo albero, insieme a tutto il resto.
Il tuo lock, su una libreria, serve solo a te e a chi contribuisce: fissa le versioni con cui girano i test. È comodo per una CI stabile, ma porta un rischio: testi sempre con le versioni del tuo lock e mai con quelle che i tuoi utenti riceveranno. Una dipendenza pubblica una minor che rompe qualcosa e tu non te ne accorgi, perché il lock ti protegge da un problema che colpisce chi ti usa.
Le due strategie sensate: committare il lock e affiancare un controllo periodico in CI che installa senza, oppure non committarlo e accettare una CI più rumorosa in cambio di un segnale più onesto. Entrambe si difendono; non fare la scelta no.
In ogni caso il lock non finisce nel pacchetto pubblicato, perché npm lo esclude da solo.
Gli hash di integrità
Quel campo integrity che sembra rumore merita una riga in più. È l'impronta crittografica del pacchetto scaricato, e viene verificata a ogni installazione: se il file non corrisponde, l'installazione si ferma.
A cosa serve davvero. Non impedisce a un manutentore di pubblicare codice malevolo in una versione nuova — quella arriverà con il suo hash, perfettamente valido. Protegge da qualcosa di più subdolo: che il contenuto di una versione già esistente cambi. Un registro compromesso, un proxy manomesso, una cache avvelenata. Con l'hash nel lock, 1.2.3 significa per sempre quei byte esatti.
È un controllo tecnico, non una valutazione di fiducia. La differenza, e cosa fare per il resto, sono in sicurezza delle dipendenze npm.
Cosa fa perdere ore
- Mettere il lock nel
.gitignore. Capita copiando un.gitignoreda un altro linguaggio, o per "ridurre il rumore nelle pull request". È il modo più veloce per perdere la riproducibilità. - Due lockfile diversi nello stesso repository. Qualcuno ha installato con lo strumento sbagliato. Da quel momento esistono due verità che divergono: cancella quello di troppo e scegli un gestore solo.
- Cancellare il lock al primo problema. A volte serve, ma aggiorna in blocco decine di dipendenze transitive. Falla su un branch, con i test che girano.
- Usare
npm installin CI. Il giorno in cui il lock diverge, la pipeline passa installando qualcosa di diverso da quello che hai verificato in locale. - Modificare il
package.jsona mano e committare senza reinstallare. I due file divergono enpm cifallisce. - Approvare pull request con migliaia di righe di lock senza guardare nulla. Non devi leggerle, ma il numero di pacchetti aggiunti è un'informazione: cinquanta nuovi per una utility da tre righe dice qualcosa.
In sintesi
Il lockfile registra l'albero delle dipendenze risolto: versioni esatte, posizione e hash di ogni pacchetto, comprese le centinaia che non hai mai installato direttamente.
Il package.json esprime un desiderio, il lock registra un fatto. Gli intervalli di versione fanno sì che due installazioni a settimane di distanza producano alberi diversi: il lock è ciò che lo impedisce.
In CI usa sempre npm ci: è più veloce e soprattutto fallisce quando lock e package.json divergono, invece di aggiustare in silenzio.
I conflitti sul lock non si risolvono a mano, si rigenerano — riprendi il lock della base, reinstalla, ricommitta.
Va committato sempre nelle applicazioni. L'unica eccezione vera sono le librerie pubblicate, dove il tuo lock non ha alcun effetto su chi ti installa.
Per capire gli intervalli di versione che il lock congela, vedi package.json: ogni campo spiegato. Per il rischio che porta ogni dipendenza, vedi sicurezza delle dipendenze npm.