Reprendre le contrôle de vos dépendances NPM

Bibliothèque futuriste en bois avec livres flottants de versions NPM, hologramme d’arbre de dépendances, et panda roux lisant « SemVer pour les nuls » sous une lumière dorée.

Je vois souvent des gens ne pas maitriser l'usage de NPM au quotidien. Des choses basiques, mais importantes, surtout quand il s'agit de se débarrasser d'une faille de sécurité dans une dépendance transitive. Je ne suis pas expert NPM, mais après quasi 10 ans à l'utiliser au quotidien, j'ai appris quelques trucs qui servent bien, et j'en apprends encore !

C'est quoi une dépendance ?🔗

En général, on répondra "tout ce qui est listé dans le package.json". Et ce n'est pas faux en soi. Mais c'est incomplet en théorie.

Déjà si vous faites du Node, vous avez au strict minimum deux dépendances : Node et npm (ou peut-être yarn ou pnpm, mais au fond vous avez quand même npm sur votre machine quoi qu'il arrive, et je ne vais pas couvrir les spécificités de yarn (que je ne maitrise pas assez) et pnpm (qui est trop intéressant pour juste faire un paragraphe dessus)). Sans ces deux outils, vous ne pouvez pas travailler au sens compiler / exécuter votre application. Donc vous dépendez de ces deux outils. Je ne vais pas rentrer plus dans le détail mais juste : pensez à mettre à jour votre version Node régulièrement, particulièrement avec le changement de cycle de release.

L'autre aspect qui manque c'est la notion de dépendance transitive. On qualifie de dépendance transitive une dépendance d'une de nos dépendances. Prenons un exemple simple : vous avez un projet SolidJS avec Vite, vous installez donc vite-plugin-solid. Vous avez donc dans votre package.json solid-js, vite et vite-plugin-solid qui sont vos dépendances directes, mais vite-plugin-solid dépend de @babel/core, babel-preset-solid, solid-js, solid-refresh, vite et vitefu, et si vous creusez encore vous verrez que babel-preset-solid dépend de @babel/core, babel-plugin-jsx-dom-expressions et solid-js, et on pourrait creuser un peu toutes les dépendances comme ça. Chaque dépendance pouvant dépendre d'une version différente d'une même dépendance (vous pouvez dépendance de solid-js@1.9.12, babel-preset-solid dépendre de solid-js@1.9.11, et babel-preset-solid dépendre de solid-js@1.9.10), ce ne serait pas forcément très stable / recommandé mais c'est possible du point de vue de NPM.

Vous avez donc des dépendances que vous maitrisez et d'autres que vous ne maitrisez pas directement. À priori.

Visualisé vos dépendances🔗

Commençons par notre package.json :

{
  ...
  "dependencies": {
    "solid-js": "^1.9.5"
  },
  "devDependencies": {
    "typescript": "~5.8.3",
    "vite": "^6.3.5",
    "vite-plugin-solid": "^2.11.6"
  }
}

On voit donc 4 dépendances ici.

Maintenant, installons nos dépendances (npm ci si vous avez un package-lock.json, npm i sinon, j'y reviendrais plus tard).

❯ npm ci # si vous n'avez pas encore de package-lock.json, utilisez npm i ou npm install

added 73 packages in 1m

13 packages are looking for funding
  run `npm fund` for details

Et là NPM nous dit avoir installé 73 packages. On est donc bien loin des 4 qu'on voyait plus haut…

Ensuite on va utiliser npm ls.

npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├── solid-js@1.9.12
├── typescript@5.8.3
├── vite-plugin-solid@2.11.12
└── vite@6.4.2

C'est logique, on retrouve les dépendances de notre package.json. Allons regarder plus en profondeur :

❯ npm ls --depth 1

npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├─┬ solid-js@1.9.12
│ ├── csstype@3.2.3
│ ├── seroval-plugins@1.5.2
│ └── seroval@1.5.2
├── typescript@5.8.3
├─┬ vite-plugin-solid@2.11.12
│ ├── @babel/core@7.29.0
│ ├── UNMET OPTIONAL DEPENDENCY @testing-library/jest-dom@^5.16.6 || ^5.17.0 || ^6.*
│ ├── @types/babel__core@7.20.5
│ ├── babel-preset-solid@1.9.12
│ ├── merge-anything@5.1.7
│ ├── solid-js@1.9.12 deduped
│ ├── solid-refresh@0.6.3
│ ├── vite@6.4.2 deduped
│ └── vitefu@1.1.3
└─┬ vite@6.4.2
  ├── UNMET OPTIONAL DEPENDENCY @types/node@^18.0.0 || ^20.0.0 || >=22.0.0
  ├── esbuild@0.25.12
  ├── fdir@6.5.0
  ├── UNMET OPTIONAL DEPENDENCY fsevents@~2.3.3
  ├── UNMET OPTIONAL DEPENDENCY jiti@>=1.21.0
  ├── UNMET OPTIONAL DEPENDENCY less@*
  ├── UNMET OPTIONAL DEPENDENCY lightningcss@^1.21.0
  ├── picomatch@4.0.4
  ├── postcss@8.5.9
  ├── rollup@4.60.1
  ├── UNMET OPTIONAL DEPENDENCY sass-embedded@*
  ├── UNMET OPTIONAL DEPENDENCY sass@*
  ├── UNMET OPTIONAL DEPENDENCY stylus@*
  ├── UNMET OPTIONAL DEPENDENCY sugarss@*
  ├── UNMET OPTIONAL DEPENDENCY terser@^5.16.0
  ├── tinyglobby@0.2.16
  ├── UNMET OPTIONAL DEPENDENCY tsx@^4.8.1
  └── UNMET OPTIONAL DEPENDENCY yaml@^2.4.2

Le fonctionnement de vite étant d'essayer de s'auto-configurer au maximum, on retrouve beaucoup de dépendances optionnelles. Épurons ces dépendances qui ne sont pas installées pour rendre l'arbre plus lisible

❯ npm ls --depth 1 | grep -v 'UNMET OPTIONAL DEPENDENCY'
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├─┬ solid-js@1.9.12
│ ├── csstype@3.2.3
│ ├── seroval-plugins@1.5.2
│ └── seroval@1.5.2
├── typescript@5.8.3
├─┬ vite-plugin-solid@2.11.12
│ ├── @babel/core@7.29.0
│ ├── @types/babel__core@7.20.5
│ ├── babel-preset-solid@1.9.12
│ ├── merge-anything@5.1.7
│ ├── solid-js@1.9.12 deduped
│ ├── solid-refresh@0.6.3
│ ├── vite@6.4.2 deduped
│ └── vitefu@1.1.3
└─┬ vite@6.4.2
  ├── esbuild@0.25.12
  ├── fdir@6.5.0
  ├── picomatch@4.0.4
  ├── postcss@8.5.9
  ├── rollup@4.60.1
  ├── tinyglobby@0.2.16

Note : grep permet de filtrer pour ne garder que les lignes qui contiennent un certain motif, mais si on ajoute l'option -v, on garde les lignes qui ne contiennent pas le motif.

Rien qu'en ajoutant un niveau de profondeur on est déjà à 20 dépendances, avec solid-js@1.9.12 et vite@6.4.2 qui taggé comme deduped, c'est à dire identifiées comme déjà résolue et donc n'ont plus besoin d'être résolus (car le package est tiré plus haut dans l'arbre et avec la même version ou une version compatible).

On peut continuer comme ça en augmentant la profondeur affichée. On peut afficher tout l'arbre directement en passant --all. On peut aussi cibler un package précis.

❯ npm ls solid-js                                       
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├── solid-js@1.9.12
└─┬ vite-plugin-solid@2.11.12
  ├─┬ babel-preset-solid@1.9.12
  │ └── solid-js@1.9.12 deduped
  ├── solid-js@1.9.12 deduped
  └─┬ solid-refresh@0.6.3
    └── solid-js@1.9.12 deduped

Ici on voit où la dépendance solid-js est tirée dans l'arbre, la version attendue, si elle est bien dédupliquée comme on s'y attend (ici c'est le cas).

En choisissant solid-js dans un projet SolidJS, on s'attend à le voir. Si vous demande pour esbuild ?

❯ npm ls esbuild 
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
└─┬ vite@6.4.2
  └── esbuild@0.25.12

Il n'est pas dans notre package.json pourtant il fait bien partie de l'arbre. Une faille sur esbuild peut donc nous impacter. Et certains me diront qu'esbuild sert uniquement à la compilation donc ne part pas en production. Ce n'est pas aussi simple. Déjà esbuild sera installé sur chaque runner de notre CI, donc à défaut d'infecter nos utilisateurs, on peut impacter notre SI via notre CI ce qui n'est déjà pas terrible. Ensuite on peut se retrouver dans un cas où la faille n'est pas dans esbuild mais dans le code qu'il produit, en effet les outils de compilation vont modifier nos sources et peuvent donc injecter du code non sûr et là ça peut retomber chez les utilisateurs.

Comment qu'on dit ce qu'on veut comme version ?🔗

Déjà il faut comprendre ce qu'on appelle le SemVer ou Semantic Versioning.

Pour faire très simple : on va définir une version sur trois nombre X.Y.Z. On va appeler X la version majeure, Y la mineure et Z le patch. L'idée va être de se dire qu'une modification qui vient corriger va juste incrémenter le patch, ensuite une nouvelle fonctionnalité qui ne casse rien pour les utilisateurs actuels incrémente uniquement le mineur, et si on casse quelque chose on incrémente le majeur.

Note : je vous laisse aller voir sur Internet pour tous les détails du SemVer, je n'irai pas plus loin ici. Il reste pas mal de chose à dire : les versions alpha/beta/RC, les versions à quatre nombres, les versionnings alternatifs compatible ou non avec SemVer, etc.

À partir de là, on se dit qu'on veut parfois dire "je veux strictement cette version", parfois "je veux bien prendre un nouveau patch mais pas de changement mineur" et de temps en temps "je veux bien tout mettre à jour sauf la majeure".

Par défaut, si vous faite npm install lodash (pareil si vous faite npm install lodash@latest), NPM va ajouter à votre package.json dans dependencies "lodash": "^4.18.1" (la dernière version sortie à date). Sauf que là vous avez peut-être mis un doigt dans un système que vous ne vouliez pas avec le ^ devant la version. En effet ^4.18.1 veut dire "n'importe quelle version 4.x.x qui est supérieur à 4.18.1". Donc si vous refaite un npm i dans quelques jours/semaines/mois vous aurez peut-être la version 4.18.1, peut-être une 4.18.2 ou peut-être une 4.45.0. Est-ce que vous vouliez prendre en compte tous ces changements simplement en installant vos dépendances ? Pas sûr (même si, je vous l'accorde : avec lodash on peut normalement mettre à jour les yeux fermés).

Si vous voulez "toujours" tirer la version 4.18.1, il faut retirer le ^ et refaire un npm i (pour mettre à jour le package-lock.json) ou simplement faire directement npm install lodash --save-exact qui va ajouter au package.json "lodash": "4.18.1".

Si vous voulez bien les mises à jours, mais seulement de patch, vous pouvez indiquer "~4.18.1".

Sauf que là ça ne concerne que les versions qu'on tire directement via notre package.json. Si je veux forcer la version 0.27.7 d'esbuild je ne peux pas fonctionner comme ça.

En effet si je fais npm i esbuild@0.27.7 --save-exact (la dernière version à date) puis que je regarde l'arbre de dépendance :

❯ npm ls esbuild      
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├── esbuild@0.27.7
└─┬ vite@6.4.2
  └── esbuild@0.25.12

On voit bien que j'ai version 0.27.7 à la racine de l'arbre, mais sous vite j'ai toujours la version 0.25.12. Donc comment on fait ? Déjà on supprime esbuild qui est inutile à la racine du projet npm uninstall esbuild. Puis on va ajouter une section overrides à notre package.json pour forcer partout dans l'arbre une certaine version d'esbuild :

{
  ...  
  "overrides": {
    "esbuild": "0.27.7"
  }
}

Là on fait npm install pour mettre à jour nos dépendances et on regarde notre arbre de dépendance :

❯ npm ls esbuild
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
└─┬ vite@6.4.2
  └── esbuild@0.27.7 overridden

Là on voit bien que la version n'est plus la même et c'est indiqué qu'elle est overridden montrant bien que ce n'est pas la version que NPM aurait choisi normalement.

À Noter qu'on peut aller assez loin avec cette mécanique, par exemple :

{
  ...  
  "overrides": {
    "esbuild": "^0.27.0",
    "vite": {
      "esbuild": "^0.27.5"
    }
  }
}

Permet d'indiquer qu'on veut surcharger la version d'esbuild en autorisant n'importe quelle version compatible avec ^0.27.0 partout dans l'arbre de dépendance sauf dans le sous-arbre de vite où on va viser ~0.26.0 (en l’occurrence, la branche 0.26.x ne contient que la version 0.26.0 donc NPM n'a pas beaucoup de choix de version). Ici ça ne sert pas à grand-chose mais dans certains cas, ça peut être utile. // TODO

❯ npm ls esbuild
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
└─┬ vite@6.4.2
  └── esbuild@0.26.0

Ce n'est pas une erreur de ma part, ici on ne voit plus l'indication overridden alors que la version l'est bien mais passons là-dessus. Par contre on voit bien que notre version est surchargée.

Et si maintenant on ajoute ça ?

{
    ...
    "overrides": {
        ...
        "vite-plugin-solid": {
           "solid-js": "1.9.4"
        }
    }
}

Normalement au moment de l'installation vous aurez une erreur du type :

❯ npm i         
npm warn ERESOLVE overriding peer dependency
npm warn While resolving: solid-refresh@0.6.3
npm warn Found: solid-js@1.9.12
npm warn node_modules/solid-js
npm warn   solid-js@"^1.9.5" from the root project
npm warn
npm warn Could not resolve dependency:
npm warn peer overridden solid-js@"1.9.4" (was "^1.3") from solid-refresh@0.6.3
npm warn node_modules/solid-refresh
npm warn   solid-refresh@"^0.6.3" from vite-plugin-solid@2.11.12
npm warn   node_modules/vite-plugin-solid
npm warn
npm warn Conflicting peer dependency: solid-js@1.9.4
npm warn node_modules/solid-js
npm warn   peer overridden solid-js@"1.9.4" (was "^1.3") from solid-refresh@0.6.3
npm warn   node_modules/solid-refresh
npm warn     solid-refresh@"^0.6.3" from vite-plugin-solid@2.11.12
npm warn     node_modules/vite-plugin-solid
npm error code ERESOLVE
npm error ERESOLVE could not resolve
npm error
npm error While resolving: vite-plugin-solid@2.11.12
npm error Found: solid-js@1.9.12
npm error node_modules/solid-js
npm error   solid-js@"^1.9.5" from the root project
npm error
npm error Could not resolve dependency:
npm error peer overridden solid-js@"1.9.4" (was "^1.7.2") from vite-plugin-solid@2.11.12
npm error node_modules/vite-plugin-solid
npm error   dev vite-plugin-solid@"^2.11.6" from the root project
npm error
npm error Conflicting peer dependency: solid-js@1.9.4
npm error node_modules/solid-js
npm error   peer overridden solid-js@"1.9.4" (was "^1.7.2") from vite-plugin-solid@2.11.12
npm error   node_modules/vite-plugin-solid
npm error     dev vite-plugin-solid@"^2.11.6" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
npm error
npm error
npm error For a full report see:
npm error /home/anthony_p/.npm/_logs/2026-04-15T05_25_31_667Z-eresolve-report.txt
npm error A complete log of this run can be found in: /home/anthony_p/.npm/_logs/2026-04-15T05_25_31_667Z-debug-0.log

Et là beaucoup vous diront "passe --force et c'est bon" ou "--legacy-peer-deps c'est mieux !". Moi je vous dirais : à moins que vous soyez absolument sûr de vous ou que vous n'ayez pas du tout le choix temporairement : ne le faites pas !

Dans mon exemple j'ai indiqué à NPM que je voulais qu'il tire une version "1.9.4" de solid-js pour vite-plugin-solid ce qui implique qu'on casse le fonctionnement des peer dependancies (dépendance qu'on attend qui soit tirée dans l'arbre mais pas forcément par notre package). Passer --force ou --legacy-peer-deps ne va pas fonctionner ici de part le fonctionnement des peer dependancies (vous aurez la 1.9.12 qui sera prise partout dans l'arbre quand même) mais passer --force ou --legacy-peer-deps va transformer les erreurs en warnings, masquant au passage (car personne ne lit les warnings) des futurs problèmes et donc vous rendant beaucoup plus difficile la montée de version de vos dépendances dans le futur…

Donc si vous arrivez dans le cas où NPM vous propose --force ou --legacy-peer-deps corriger la source du problème, mais n'utilisez pas ces arguments !

npm i, npm ci, npm audit et package-lock.json🔗

Je vois souvent des gens utiliser sans distinction npm i et npm ci. Les deux sont fondamentalement différents dans l'usage :

  • npm i va lire votre package.json puis va interroger le registry NPM (par défaut https://npmjs.com/) pour résoudre chaque dépendance (directe et transitive), voir s'il y a une nouvelle version à tirer qui correspond au pattern qu'on a indiqué, puis une opération de dédoublonnement va rentrer en œuvre, pour enfin créer votre dossier node_modules et créer (ou mettre à jour) le package-lock.json qui va être une image de votre dossier node_modules avec tout l'arbre, les checksums pour valider que les packages n'ont pas changé (et donc n'ont pas été infectés depuis la dernière fois où vous les avez téléchargés) ;
  • npm ci quant à lui va lire le package-lock.json, vérifier qu'il est bien synchronisé avec le package.json (une section au début du package-lock.json reprend le contenu des dependencies et devDependencies du package.json), puis va télécharger en vérifiant les checksums les versions indiquées dans le package-lock.json, sans plus de traitement, pour créer le dossier node_modules ;

Donc autrement dit : npm i est là pour modifier / mettre à jour vos dépendances, npm ci pour les installer. Si vous utilisez continuellement npm i vous allez continuellement mettre à jour vos dépendances (en plus de largement perdre du temps sur cette étape), alors qu'avec npm ci vous allez installer les mêmes versions que toute l'équipe et que votre CI donc vous êtes certain que vous n'aurez pas d'écart à l'exécution. C'est encore plus important si vous exécutez du Node.js côté serveur, parce qu'avec des npm i vous n'avez aucune garantie que l'exécution se fera avec les mêmes versions de dépendances en production qu'en local.

Et npm audit qu'on voit régulièrement quand on fait des npm i ? C'est une commande qui va inspecter vos dépendances et va vous indiquer si c'est possible de mettre à jour des packages dans l'arbre de dépendance pour corriger des failles de sécurité. npm audit vous donnera les infos, npm audit fix va appliquer tout ce qui est sans risque d'un point de vue SemVer. Dans la plupart des cas, npm audit fix va essentiellement modifier le package-lock.json pour changer une version d'une dépendance transitive, donc vous ne voyez rien côté package.json.

Donc votre package-lock.json de facto n'est pas une simple image de votre package.json qui est généré et jetable sans questionnement. C'est un fichier qui a une vraiment importance en termes de sécurité et de garantie de fonctionnement dans le temps.

Autant que possible, aujourd'hui, évitez de supprimer le package-lock.json.

Note : c'est un peu opaque de prime abord mais si vous utilisez la fonctionnalité d'installation des dépendances via IntelliJ (ou autre IDE de la suite de Jetbrains), il lancera la commande npm install, pas npm ci. J'imagine qu'ils ont fait ça pour éviter d'avoir à tester si le package-lock.json est synchronisé avant de lancer la commande npm ci ou npm i, mais c'est une erreur fondamentale de fonctionner comme ça…

Renovate / Dependabot🔗

Il existe des outils pour vous aider à maintenir vos dépendances à jour sans trop d'effort, particulièrement si vous avez un bon pipeline qui s'exécute sur vos MR et qui valide assez largement votre application !

Quelques astuces :

  • Mettez toutes vos versions en mode exacte (ne pas mettre de ~ ou ^ avant la version) ;
  • Forcez-vous à mettre de l'auto-merge sur les patchs (normalement c'est censé être gratuit), sauf sur les dépendances internes à votre entreprise (j'ai rarement vu une librairie interne qui suivait entièrement le SemVer et j'ai toujours eu des soucis, donc dans le doute…) ;
  • définissez un rôle tournant dans votre équipe de mise à jour des dépendances (essentiellement : regarder si le pipeline passe sur les branches de Renovate / Dependabot et cliquer sur "merge") ;

Note : si on vous bloque pour une quelconque raison l'usage de Renovate / Dependabot, NPM fourni la commande npm outdated qui va vous indiquer toutes les mises à jours que vous pouvez faire. À défaut d'un outil qui travail à votre place, l'exécuter une fois par semaine vous permettra de rester plutôt à jour à moindre effort. Et si un jour vous trouvez npm outdated trop lent, vous pouvez regarder du côté de taze qui est plus rapide et permet de mettre à jour par lot les dépendances dans le package.json.

Astuce : SemVer Calculator🔗

Si vous avez dû mal à identifier quelles versions seront tirées pour un certain pattern, vous pouvez utiliser le npm SemVer Calculator. C'est un outil en ligne fourni par NPM pour visualiser les versions compatibles avec un certain pattern.

SemVer Calculateur avec une démonstration sur une version de lodash
SemVer Calculateur avec une démonstration sur une version de lodash

Par défaut c'est lodash qui est pointé, mais vous pouvez cibler un autre package, vous pouvez tester n'importe quel pattern de version. C'est vraiment pratique pour valider un pattern très vite, et comprendre comment tout ça fonctionne par la pratique.

Conclusion🔗

Les dépendances avec NPM c'est parfois un peu compliqué. Surtout parce qu'en général on ne prend pas le temps de se projeter sur le comment ça fonctionne vraiment avant d'avoir un problème et se battre avec. C'est souvent un moment où on s'est embourbé avec des mois de --force ou --legacy-peer-deps, où on a tout cassé et là on dit "NPM c'est de la m**de", alors que juste on s'est loupé.

NPM fait partie de ses outils avec des défauts historiques mais qui ont tendances à nous laisser décider pas mal de choses et de facto demande de comprendre un minimum ce qu'on fait pour que ça fonctionne dans le temps.

En tout cas, j'espère que cet article vous aidera à mieux gérer vos dépendances NPM ! 🤓

Sources :

Crédit photo : Générée via Mistral AI avec le prompt suivant :

Un style graphique inspiré de l'anime japonais Studio Ghibli, avec une ambiance chaleureuse, immersive, poétique et onirique. Une bibliothèque futuriste en bois sculpté, avec des escaliers en colimaçon, des échelles mobiles, et des étagères remplies de livres flottants. Chaque livre représente une version de package NPM (ex: lodash@4.18.1, esbuild@0.27.7) avec des titres lumineux et des couvertures colorées. Au centre, un grand hologramme central montre un arbre de dépendances NPM avec npm ls --all, avec des lignes lumineuses reliant les livres entre eux pour symboliser les relations entre les dépendances. En bas à droite, un panda roux anthropomorphe (style Ghibli, vêtements amples) est assis sur un tapis en tissu, un livre ouvert sur ses genoux (titre visible : "SemVer pour les nuls"), avec une tasse de thé fumant à côté. L'arrière-plan est rempli de détails organiques : plantes grimpantes, feuilles d'érable rouges/oranges/jaunes tombant doucement, et une lumière dorée tamisée évoquant un après-midi d'automne. Les couleurs dominantes sont des tons doux de beige, bleu pâle, orange chaud, et des accents de vert et de doré pour les éléments technologiques et naturels. L'image doit être détaillée, immersive, avec des textures riches et une atmosphère à la fois cosy et magique, mêlant technologie et nature de manière harmonieuse.