~8 min de lecture
knip sur Angular + Nx : traquer le code mort sans crouler sous les faux positifs
Tu as une dépendance dans ton package.json que plus personne n'importe. Un export public que zéro fichier ne consomme.
Une interface de props laissée là après un refacto. Rien de tout ça ne casse le build, rien ne fait planter les tests.
La dette de code mort se reconstitue en silence : chaque dépendance fantôme est une ligne de plus à auditer à chaque
faille de sécurité, chaque export orphelin une fausse piste pour le prochain qui lira le fichier.
knip est l'outil qui débusque ça. Mais si tu le lances tel quel sur un projet Angular SSR + Nx, il va te noyer sous les
faux positifs dès le premier run. Voici comment je l'ai câblé proprement sur ce repo, et le piège exact que j'ai rencontré.
Le problème : trois types de code mort, zéro alarme
Sur un audit du repo, trois choses sont remontées. Je ne les ai pas dénichées à la main : elles sont sorties d'un audit automatisé qu'on fait tourner sur le repo EasyAngularKit, sous forme de findings priorisés. knip n'était qu'un des outils du passage. Aucune des trois ne déclenchait quoi que ce soit en local ou en CI.
Une dépendance fantôme. @angular/forms était listé dans le package.json. Zéro import runtime sous src/. Pas un
FormsModule, pas un ReactiveFormsModule, pas un FormControl. La seule occurrence du mot "form" vivait dans le contenu
rédactionnel d'articles de blog. La dépendance était installée, téléchargée, versionnée, pour rien.
Des exports jamais consommés. Des helpers comme getModuleBySlug et getModuleByIndex, une interface CtaButtonProps
définie puis abandonnée. Du code que TypeScript compile sans broncher parce qu'un export est valide même sans consommateur.
Des exports faussement publics. getDisplayTitle et toModuleCardData étaient exportés, mais utilisés uniquement
en interne par la constante MODULE_CARDS dans le même fichier. L'export était mort, pas la fonction.
Le point commun : aucun de ces trois cas n'est détectable par le compilateur ni par les tests. Il faut un outil qui construise le graphe de dépendances complet du projet et cherche les noeuds que personne ne pointe.
knip brut = bruyant
Premier réflexe : pnpm dlx knip. Et là, douche froide. La sortie liste comme "unused files" des fichiers qui sont au
contraire critiques :
Unused files (5)
src/main.server.ts
src/server.ts
src/app/app.config.server.ts
src/app/app.routes.server.ts
src/styles.css
Unused dependencies (1)
@angular/forms
Unused devDependencies (12)
@angular/cli
@angular/language-service
@nx/workspace
prettier
...
Le @angular/forms est un vrai positif, bravo knip. Mais le reste ?
src/main.server.ts, src/server.ts, src/app/app.config.server.ts, src/app/app.routes.server.ts sont les
entrypoints SSR d'Angular. Personne ne les importe depuis le code applicatif, et c'est normal : ce sont les points
d'entrée que le build serveur et le prerender consomment via les targets server et prerender déclarées dans
project.json. knip ne lit pas la config Nx. Pour lui, un fichier que rien n'importe est un fichier mort. Il a tort, mais
il ne peut pas savoir. (Le cinquième, src/styles.css, est un autre cas : il sortira du radar dès qu'on restreindra le
périmètre d'analyse au TypeScript, on y revient.)
Même logique pour les douze devDependencies. @angular/cli, @nx/workspace, prettier, typescript-eslint... aucune
n'est importée dans du code source. Elles sont invoquées via la CLI ou référencées par des fichiers de config
(eslint.config.js, .prettierrc) que knip ne relie pas toujours à leur binaire. Faux positifs, tous.
Le danger ici est insidieux. Un outil de qualité qui crie au loup à chaque run, c'est un outil qu'on finit par ignorer, ou pire, qu'on désactive. Le bruit tue le signal. Si tu veux que knip serve à quelque chose en CI, il faut d'abord lui apprendre la topologie réelle de ton projet.
La config qui marche
Tout se joue dans un knip.json minimal. Voici celui du repo, commenté :
{
"$schema": "https://unpkg.com/knip@5/schema.json",
// Les points d'entree que knip ne peut pas deviner.
// main.ts est evident ; les *.server.ts + app.config.server.ts
// + app.routes.server.ts sont consommes par les targets Nx
// `server` et `prerender`, pas par un import applicatif.
// tools/*.ts = scripts SSG (generation blog, sitemap, rss) lances en CLI.
"entry": [
"src/main.ts",
"src/main.server.ts",
"src/server.ts",
"src/app/app.config.server.ts",
"src/app/app.routes.server.ts",
"tools/*.ts"
],
// Le perimetre a analyser. On reste sur le TS source.
"project": ["src/**/*.ts", "tools/**/*.ts"],
// Un export utilise UNIQUEMENT dans son propre fichier n'est plus
// signale comme mort. Sans ca, les types d'API publique references
// en interne (ConsentPreferences, ModuleVideoData...) ressortent
// en faux positifs a chaque run.
"ignoreExportsUsedInFile": true,
// Le tooling invoque via CLI ou config, que knip ne relie pas
// a un import. On l'autorise explicitement plutot que de subir
// 12 fausses alertes a vie.
"ignoreDependencies": [
"@angular/cli",
"@angular/language-service",
"@eslint/js",
"@nx/js",
"@nx/workspace",
"@semantic-release/github",
"@swc/helpers",
"@tailwindcss/typography",
"angular-eslint",
"conventional-changelog-conventionalcommits",
"prettier",
"typescript-eslint"
]
}
Trois leviers, trois intentions distinctes :
entrydit à knip : « ces fichiers sont des racines du graphe, ne les compte jamais comme morts, et suis ce qu'ils importent ». C'est ce qui éteint les quatre entrypoints SSR.ts. Le cinquième faux positif,src/styles.css, tombe tout seul : en restreignantprojectàsrc/**/*.tsettools/**/*.ts, le CSS n'est plus dans le périmètre analysé, donc plus jamais signalé.ignoreExportsUsedInFileest le réglage le plus sous-estimé. Par défaut, knip considère qu'unexportdoit être consommé depuis un autre fichier. Or un type d'API publique (unConsentPreferences, unModuleVideoData) est souvent défini et ré-utilisé dans son propre fichier de constantes. Sans ce flag, tu te tapes une liste d'exports "morts" qui ne le sont pas. Avec, knip ne flag que ce qui est vraiment orphelin à l'échelle du projet.ignoreDependenciesest une liste d'exceptions assumée, pas un tapis sous lequel on planque la poussière. Chaque entrée est du tooling qu'on sait invoqué hors import. La règle que je me fixe : si tu ne peux pas expliquer en une phrase pourquoi une dép est dans cette liste, c'est qu'elle n'a rien à y faire.
Le piège à éviter absolument : ne mets pas tes vraies dépendances mortes dans ignoreDependencies pour faire taire
knip. C'est exactement l'inverse du but. @angular/forms n'a jamais touché cette liste : il a été retiré du projet.
Le câbler en CI : l'anti-régression
Une config locale, c'est bien. Une gate en CI, c'est ce qui empêche la dette de revenir. Deux étapes.
Le script dans package.json :
{
"scripts": {
"lint:deps": "knip"
}
}
Et l'étape dans le workflow GitHub Actions, après le build/lint/test :
- run: pnpm nx run-many -t build lint test --skip-nx-cache
- name: Dead code & dependencies (knip)
run: pnpm run lint:deps
knip sort en code non-zéro dès qu'il trouve du code mort. À partir de là, toute PR qui réintroduit un export orphelin, une dépendance jamais importée ou un fichier injoignable fait rougir la CI. Le coût d'un ré-import mort passe de « découvert six mois plus tard par hasard » à « bloqué dans les trente secondes de la PR ». C'est tout l'intérêt : transformer une revue manuelle aléatoire en garde-fou automatique.
Ce que ça a vraiment trouvé
Pas de la théorie. Une fois la config en place et le bruit éteint, voilà ce qui restait, et qui a été nettoyé :
@angular/formsretiré dupackage.json. Une dépendance Angular complète qui ne servait plus à rien.getModuleBySlugetgetModuleByIndexsupprimés : des helpers sans aucun appelant.- L'interface
CtaButtonPropssupprimée : jamais référencée nulle part. getDisplayTitleettoModuleCardDatadé-exportés : conservés parce qu'utilisés en interne parMODULE_CARDS, mais leurexportmort a sauté. La fonction reste, sa surface publique fictive disparaît.
Après nettoyage : knip sort en exit 0, aucun finding. Le build pré-rend toujours ses routes sans broncher, les
tests restent verts. Le diff net, c'est moins de surface d'API à maintenir et un graphe de dépendances qui dit enfin la
vérité sur ce que le projet utilise vraiment.
knip vs depcheck vs ts-prune
La question légitime : pourquoi knip et pas les outils qu'on connaît déjà ?
Avant, il fallait empiler plusieurs outils pour couvrir le même périmètre :
depcheckpour les dépendances inutilisées dupackage.json.ts-prunepour les exports TypeScript orphelins.- Un script maison ou un autre outil pour les fichiers carrément injoignables.
Trois outils, trois configs, trois formats de sortie, trois sources de faux positifs à dompter séparément. knip fait les trois dimensions dans un seul passage : fichiers morts, dépendances mortes, exports morts, avec une config unique. Sur un monorepo Nx où le graphe est déjà compliqué, ne maintenir qu'un seul outil de détection change la donne.
Est-ce que knip est parfait ? Non. Le prix d'entrée, c'est précisément le knip.json qu'on vient d'écrire : sans lui,
l'outil est inutilisable sur un projet SSR + Nx. Mais ce coût est payé une fois, et il rapporte à chaque PR ensuite.
Ce qu'il faut retenir
Le code mort ne déclenche aucune alarme par défaut. Ni le compilateur, ni les tests, ni le linter standard ne le voient. Il faut un outil qui construise le graphe complet et cherche les orphelins.
Sur Angular SSR + Nx, knip brut est inexploitable. Il flag les entrypoints serveur et le tooling CLI comme morts
parce qu'il ne lit pas la config Nx. La valeur n'est pas dans npx knip, elle est dans le knip.json qui lui apprend ta
topologie.
ignoreDependencies est une liste d'exceptions, pas une poubelle. Si tu ne sais pas justifier une entrée en une
phrase, retire la dépendance au lieu de la masquer.
Mets-le en CI ou ne le mets pas du tout. Un détecteur de code mort qu'on lance à la main une fois par trimestre ne sert à rien. En gate de PR, il transforme la régression silencieuse en échec immédiat. C'est le seul mode où il gagne sa place.
Ce nettoyage n'est pas un one-shot : il fait partie d'un audit systématique du repo EasyAngularKit, qui a remonté 37 findings priorisés, dont celui-ci. La vraie discipline n'est pas de lancer knip une fois et de cocher la case, c'est de transformer chaque finding en gate CI plutôt qu'en TODO qu'on oubliera. Un audit te donne la photo à l'instant T. Une gate, elle, t'empêche d'y retourner.