Journal des frictions

Ce qui a résisté en conditions réelles : la règle trop stricte, le cas limite imprévu, le contrôle qui rendait un verdict favorable sans rien vérifier.

Ce qui a résisté, en conditions réelles. Chaque entrée note le symptôme, la cause et ce qu'on en a fait. Une friction résolue devient une entrée du CHANGELOG.

La règle : on écrit ici à chaud, pendant qu'on bute. Une friction reconstruite après coup perd ce qui la rendait instructive.


F-001 : une config de frontières qui passe au vert sans rien vérifier

Symptôme. npm run lint restait vert sur un projet où core/ importait une feature.

Cause. Deux défauts cumulés dans eslint-config-maedow-arch :

  1. Les patterns ciblaient app/** et core/** alors que le template génère dans src/.
  2. Sans résolveur TypeScript, eslint-plugin-boundaries classe tout import .ts ou .tsx en « unknown ». Or une dépendance inconnue ne déclenche aucune règle.

Ce qu'on en a fait. Les patterns ne sont plus ancrés à la racine : core couvre aussi bien core/ que src/core/. Et eslint-import-resolver-typescript devient une peerDependency obligatoire, signalée comme telle dès le README du package.

La leçon, et elle vaut au-delà d'ESLint. Un lint vert ne prouve rien tant qu'on n'a pas vu la règle échouer. D'où npm run test:boundaries, dont l'essentiel porte sur la fixture invalid/ : cinq imports interdits doivent produire cinq erreurs. Le test positif seul aurait laissé passer ce défaut.


F-002 : features/_shared classé comme une feature ordinaire

Symptôme. La règle interdisant à features/_shared/ d'importer une feature ne se déclenchait jamais.

Cause. L'élément feature (features/*) était déclaré avant shared-feature (features/_shared). Le premier pattern qui matche l'emporte : features/_shared tombait donc dans feature, et shared-feature ne matchait plus rien.

Ce qu'on en a fait. L'ordre de déclaration a été inversé, avec un commentaire en tête du fichier. Cet ordre est une contrainte de correction, pas une préférence de style.


F-003 : un projet fraîchement scaffoldé ne démarrait pas

Symptôme. npx create-maedow-arch-app demo && npm install && npm run dev échouait.

Cause. Le package.json.template déclarait les scripts next dev, next build et eslint . sans avoir next, react, react-dom ni eslint en dépendances. Le dossier src/app/ ne contenait qu'un .gitkeep, donc aucun layout.tsx et rien à servir. Enfin zod figurait en devDependencies alors que le code produit par scaffold-domain.mjs l'importe à l'exécution.

Ce qu'on en a fait. Les dépendances ont été complétées, zod est passé en dependencies, et le template porte désormais layout.tsx, page.tsx, eslint.config.mjs, next.config.mjs, vitest.config.ts ainsi qu'un .gitignore.

La leçon. Un générateur ne se teste pas en lisant son template. Il se teste en le lançant, puis en lançant ce qu'il produit.


F-004 : npm supprime les .gitignore des packages publiés

Symptôme. Le .gitignore du template aurait disparu du tarball publié. Le défaut restait invisible en test local, puisqu'on y exécute la CLI depuis le dépôt.

Cause. npm exclut d'office les fichiers nommés .gitignore des packages.

Ce qu'on en a fait. Le template le transporte sous le nom _gitignore, et la CLI le renomme à la génération. Contrôlé par npm pack --dry-run, qui liste le contenu réel du tarball.

La leçon. Tester depuis le dépôt et tester depuis le paquet publié sont deux tests différents.


F-005 : une duplication manuelle qui avait déjà divergé

Symptôme. Le site servait une version tronquée de la documentation. La page architecture avait perdu 23 % de son contenu, models 38 %, conventions 45 %, et toutes affichaient encore l'ancienne marque. Rien ne le signalait.

Cause. Les .mdx du site étaient une copie manuelle des .md de référence, faite une fois puis jamais resynchronisée.

Ce qu'on en a fait. site/scripts/sync-docs.mjs dérive les pages depuis les documents de la racine, en predev et prebuild. Le troisième exemplaire, le dossier docs/, a été supprimé. Les .mdx générés sont désormais exclus du versionnement.

La leçon. Une source de vérité dupliquée à la main n'en est plus une. Elle diverge, et le pire est qu'elle diverge en silence.


F-006 : deux paquets d'un même framework qui dérivent sous ^

Symptôme. next build échouait sur TypeError: a.map is not a function, dans une trace minifiée qui pointait vers la route de recherche. Une piste a été suivie puis abandonnée, celle du schéma Orama, et deux contournements ont été écrits pour rien.

Cause. fumadocs-mdx@11.10.1 renvoie { files: () => [...] }, c'est-à-dire une fonction, quand le loader() de fumadocs-core@15.8.5 appelle files.map(...) et attend donc un tableau. Les plages ^ avaient laissé les deux paquets dériver l'un de l'autre. L'erreur de recherche n'était qu'un symptôme aval : l'arbre de pages restant vide, les breadcrumbs valaient undefined.

Ce qu'on en a fait. Un raccord explicite et commenté dans site/src/lib/source.ts, et les trois paquets fumadocs-* épinglés à des versions exactes.

La leçon. npm run build n'avait jamais été lancé sur ce projet. Seul next dev l'avait été, et il masquait l'erreur sur les routes non visitées. Un projet dont on n'a jamais produit le build de production n'est pas un projet qui marche.

Soldé le 2026-08-30. La migration vers fumadocs-core et fumadocs-ui 16.15.4 avec fumadocs-mdx 15.4.0 a retiré le raccord. Elle a coûté plus cher que prévu : fumadocs-ui 16 exige next@16.x.x, la migration en entraînait donc une seconde, de Next 15 vers 16. Trois ruptures d'API à traiter, plus une quatrième non documentée dans les notes de version : fumadocs-mdx 15 ne génère plus un .source/index.ts unique mais plusieurs points d'entrée, et il faut importer depuis .source/server. Au passage, next lint n'existe plus en Next 16, et le script correspondant du site a été retiré plutôt que laissé mort.


F-007 : npm refuse désormais toute publication sans 2FA

Symptôme. npm publish --access public sur un compte fraîchement créé renvoyait un 403 Forbidden.

Two-factor authentication or granular access token with bypass 2fa
enabled is required to publish packages.

Cause. npm a durci sa politique. La publication directe exige la 2FA sur le compte, ou un granular access token explicitement autorisé à la contourner. Un compte neuf ayant la 2FA désactivée par défaut, publier reste impossible tant qu'on ne l'a pas activée.

Une fois la 2FA en place, le refus change de nature : EOTP, un code à usage unique est réclamé à chaque publication.

Ce qu'on en a fait. 2FA activée en auth-and-writes, puis publication.

La leçon. Créer le compte npm ne suffit pas à pouvoir publier. Pour un projet qui vise une publication automatisée en CI, il faut prévoir dès le départ un granular access token limité aux paquets concernés. C'est la seule voie non interactive qui reste ouverte.


F-008 : Vercel ne voit pas un dépôt fraîchement transféré

Symptôme. La création du projet Vercel sur maedow-arch/maedow-arch-docs était refusée.

400 : To link a GitHub repository, you need to install the GitHub
integration first.

Cause. L'App GitHub de Vercel était installée sur le compte personnel, pas sur l'organisation maedow-arch créée le jour même. Une App GitHub s'installe par compte ou par organisation : transférer un dépôt ne transfère pas ses autorisations.

Ce qu'on en a fait. Installation de l'App sur l'organisation, puis création du projet avec site/ comme répertoire racine et le déploiement continu sur main.

La leçon. Après un transfert de dépôt vers une organisation, toutes les intégrations tierces sont à réautoriser. Le dépôt est identique, ses autorisations ne le sont plus.


F-009 : baseUrl déprécié, remonté par un vrai projet

Symptôme. Sur un projet généré par la CLI, l'éditeur signale une erreur dans tsconfig.json :

Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.

Cause. Le template déclarait "baseUrl": "." en plus de "paths". C'était nécessaire avant TypeScript 4.1, plus depuis. TypeScript 6 déprécie l'option et la 7 la supprimera. La version installée dans le projet importe peu, puisque l'éditeur analyse avec la sienne : l'alerte apparaît dès que l'environnement de développement est à jour.

Ce qu'on en a fait. baseUrl a été retiré du template et des deux fixtures de test. Une seconde correction s'est imposée dans la foulée : sans baseUrl, les substitutions de paths doivent être relatives. Le compilateur est explicite sur ce point, Non-relative paths are not allowed when 'baseUrl' is not set. L'alias est donc devenu "@/*": ["./src/*"].

Vérifié par tsc --noEmit sur un projet réellement scaffoldé, contenant un import par alias, sous TypeScript 5.9 puis sous 7.0.2. Aucune erreur dans les deux cas.

En marge, une contrainte à connaître. En vérifiant sous TypeScript 7, le lint s'est mis à échouer avec un code de sortie 2, celui d'une erreur fatale de configuration et non d'une violation. Le message est sans ambiguïté : typescript-eslint does not support TS 7.0. Le tsconfig.json généré est donc valide sous TypeScript 5, 6 et 7, mais la chaîne de lint, elle, ne suit pas encore. C'est la raison pour laquelle le template continue d'épingler typescript@^5.7.0. Ce pin est délibéré, pas un oubli.

La leçon. Une option de configuration héritée survit longtemps après être devenue inutile, parce que rien ne la remet en cause tant que tout compile. Ici, c'est l'usage sur un vrai projet qui l'a fait apparaître, pas la chaîne de vérification : celle-ci utilisait une version de TypeScript qui ne signalait rien encore. Un outillage qui n'est testé que sur les versions qu'il épingle ne voit pas venir ce que ses utilisateurs voient déjà.


F-010 : le risque pnpm, anticipé puis démenti

Le soupçon. pnpm n'aplatit pas node_modules. Or eslint-config-maedow-arch déclare trois peerDependencies et charge eslint-plugin-boundaries depuis sa propre position dans l'arborescence. C'est exactement le type de résolution que l'arborescence stricte de pnpm met en défaut. Et d'après F-001, une résolution qui échoue ne fait pas hurler ESLint : elle le fait passer au vert. Un utilisateur pnpm aurait donc pu croire ses frontières protégées alors qu'elles ne l'étaient pas.

Ce qu'on a mesuré. Un projet généré, installé par pnpm install, puis soumis au test négatif. Les frontières se déclenchent normalement :

Maedow Arch : core ne peut pas importer feature. Voir « Règle de dépendance et frontières » dans architecture.md
lint sain          EXIT 0
lint avec violation  EXIT 1

Le lint, le typecheck et le build passent également.

Ce qu'on en a fait. Rien à corriger, mais la vérification est désormais permanente. La CI exécute une matrice de six combinaisons, deux variantes de template sous npm, pnpm et bun, chacune allant jusqu'au test négatif.

La leçon. Un risque théorique se vérifie, il ne se suppose pas. Celui-ci était plausible et documenté par le comportement de F-001, mais il ne se matérialise pas. L'inscrire ici évite qu'on le redoute à nouveau dans six mois, et la matrice de CI garantit qu'on le saura le jour où il apparaîtra vraiment.


F-011 : le risque de résolution s'est produit, mais pas là où je l'attendais

Le contexte. En F-010, j'avais anticipé que l'arborescence stricte de pnpm casserait la résolution de eslint-plugin-boundaries depuis eslint-config-maedow-arch, avec pour conséquence un lint vert qui ne vérifie rien. Mesure faite, pnpm tenait parfaitement.

Ce qui s'est passé. À la première exécution de la matrice de CI, les quatre combinaisons sous pnpm passaient, et les huit autres échouaient. Sous npm comme sous bun :

Cannot find package 'eslint-plugin-boundaries' imported from
packages/eslint-config-maedow-arch/index.js

Cause. La CI installait la config locale par un lien file:. npm et bun résolvent ce lien par un symlink vers le dossier réel du dépôt, qui n'a pas de node_modules. Le paquet y cherchait donc ses peerDependencies, sans les trouver. pnpm, lui, matérialise le paquet dans le store et la résolution aboutit, d'où l'inversion complète du résultat attendu.

Ce que cela ne change pas pour les utilisateurs. Un projet qui installe eslint-config-maedow-arch depuis le registre place le paquet dans son propre node_modules, où la résolution remonte naturellement jusqu'au plugin. Vérifié à plusieurs reprises sur des projets réels. L'échec était donc un artefact du banc d'essai, pas un défaut du paquet.

Ce qu'on en a fait. La CI empaquette désormais la config par npm pack et installe le tarball obtenu, ce qui reproduit exactement ce qu'un utilisateur reçoit. Reproduit puis vérifié en local avant d'être poussé : lint EXIT 0 là où la CI renvoyait 2.

La leçon, et elle en dit long. Le risque était réel, mon diagnostic de son emplacement était faux. J'avais désigné pnpm parce que son arborescence stricte est réputée fragile sur ce point, et c'est npm, le plus courant, qui trébuchait. Un risque anticipé n'est pas un risque compris : seule la matrice l'a localisé.

C'est aussi F-004 qui revient sous un autre visage. Tester par un lien vers le dépôt et tester par le paquet publié ne sont pas le même test, et c'est la deuxième fois que cette confusion produit un défaut.


F-012 : un contrôle qui ne voyait pas ce qu'il prétendait garantir

Symptôme. Après la réécriture éditoriale, j'annonçais zéro tiret cadratin dans le dépôt. Il en restait un, dans un commentaire de site/src/lib/source.ts.

Cause. Je vérifiais avec grep " — ", c'est-à-dire la chaîne entourée d'espaces. L'occurrence survivante se trouvait en fin de ligne, suivie d'un retour à la ligne et non d'une espace. Le motif ne pouvait pas la voir.

Ce qu'on en a fait. Le contrôle porte désormais sur le caractère lui-même, grep "—", sans hypothèse sur ce qui l'entoure. La consigne en mémoire persistante a été corrigée dans le même sens.

La leçon. Ce n'est pas le résultat qui était faux, c'est la méthode. Un contrôle qui repose sur une hypothèse implicite sur la forme de ce qu'il cherche finit par la démentir. C'est le même mécanisme qu'en F-001 : là un lint vert ne prouvait rien, ici un grep vide ne prouvait rien.


F-013 : deux utilitaires Tailwind de même spécificité, et la bordure disparaît

Symptôme. Dans la démonstration Tailwind, le bouton fantôme apparaissait sans bordure, là où la version en CSS natif en montrait une. Le défaut n'était visible qu'à la comparaison des deux captures.

Cause. La classe de base posait border border-transparent, la variante ajoutait border-line. Ces deux utilitaires de couleur de bordure ont la même spécificité : c'est l'ordre du CSS généré qui les départage, pas l'ordre des classes dans la chaîne. Le résultat n'était donc pas celui que la lecture du code laissait attendre.

Ce qu'on en a fait. La base ne pose plus que border, et chaque variante déclare sa propre couleur. La règle générale : ne jamais poser deux fois la même propriété via des utilitaires, dans une base puis dans une variante.

La leçon. Un build vert et un typecheck vert ne disent rien du rendu. Ce défaut n'a été trouvé que parce que le plan exigeait une comparaison visuelle entre les deux variantes.


F-014 : le nettoyage des tirets cadratins avait cassé deux fichiers YAML

Symptôme. Aucun, pendant deux jours. Les gabarits d'issue étaient invalides sur GitHub, et rien ne le signalait. C'est l'ajout de Prettier qui les a fait apparaître, en refusant de les analyser.

.github/ISSUE_TEMPLATE/friction.yml
SyntaxError: Nested mappings are not allowed in compact mappings (2:14)

Cause. En remplaçant le tiret cadratin par un deux-points, j'ai produit des scalaires YAML non quotés contenant :, ce que le format interprète comme une imbrication.

description: Signaler ce qui a résisté en conditions réelles : règle trop stricte

Le même défaut avait déjà frappé le frontmatter de la page d'accueil de la documentation le jour même, où le build l'avait attrapé. Ici rien ne compilait ces fichiers : ils partaient sur GitHub tels quels, et l'interface se contentait de ne pas proposer le formulaire.

Ce qu'on en a fait. Les valeurs sont passées entre guillemets, et le contrôle Prettier de la CI empêchera la récidive.

La leçon. Une substitution mécanique dans un fichier de configuration change sa syntaxe, pas seulement son texte. J'avais pourtant vu ce défaut une première fois, dans le frontmatter, sans en tirer la conclusion générale : chercher toutes les occurrences du même motif au lieu de corriger celle qui venait de se manifester.

Et c'est le premier apport concret de Prettier, avant même la question du style : un formateur est aussi un analyseur syntaxique qui refuse ce qu'il ne comprend pas.


F-015 : une règle active, chargée sans erreur, et qui ne regardait rien

Symptôme. En écrivant l'entrée stricte, j'ajoute import/no-cycle et sa fixture : deux modules de core/ qui s'importent mutuellement. Le test ne détecte aucun cycle. Aucune erreur, aucun avertissement, aucun message de configuration invalide. La règle est chargée, la fixture est lue, et le lint est vert.

Cause. Pour suivre une chaîne d'imports, la règle n'analyse pas seulement le fichier courant : elle ouvre elle-même les fichiers importés. Le parser déclaré dans la configuration ESLint ne s'applique qu'au fichier courant, et eslint-plugin-import a besoin d'un réglage à lui, import/parsers, pour savoir analyser ce qu'il ouvre. Sans ce réglage, il ne comprend rien aux fichiers TypeScript qu'il ouvre, n'y trouve aucun import, et conclut donc qu'il n'y a pas de cycle.

Ce qui l'a rendu visible. Le doute est venu d'un écart : le résolveur fonctionnait, import/no-unresolved signalait bien un import cassé. Le plugin voyait donc les imports. J'ai alors reconstruit le même cycle en JavaScript pur, et il a été détecté immédiatement. Vert en TypeScript, rouge en JavaScript, sur une configuration identique par ailleurs : c'est cet écart qui a désigné le coupable, et non la lecture du code de la règle.

Ce qu'on en a fait. import/parsers est déclaré dans l'entrée stricte, avec le commentaire qui explique pourquoi il n'est pas facultatif. La fixture, elle, était déjà là : c'est elle qui a trouvé le défaut.

La leçon. Le journal comptait déjà F-001, F-011 et F-012, trois formes du même mode de défaillance : un contrôle qui rend un verdict favorable sans avoir rien vérifié. Celle-ci en ajoute une quatrième, et la plus difficile à voir, parce que rien n'y est cassé. La configuration est valide, la règle est bien chargée, le plugin est bien résolu, et le fichier est bien lu. Seule une hypothèse implicite est fausse : que l'outil analyse de la même façon le fichier qu'on lui donne et ceux qu'il va chercher.

D'où la règle du dépôt, à laquelle il n'y a pas d'exception : une règle n'entre pas dans une configuration sans une fixture qui échoue quand on la retire. Non pour documenter la règle, mais parce que c'est le seul moyen de savoir qu'elle regarde quelque chose. Écrite sans sa fixture, celle-ci serait entrée dans un paquet publié, et son premier utilisateur aurait cru ses cycles surveillés.


F-016 : le seul geste du projet qui n'a aucun filet

Le contexte. Tout ce qui entre dans ce dépôt est vérifié. Vingt-sept contrôles, des fixtures qui échouent quand on retire une règle, un garde qui refuse une pull request venant d'ailleurs que de develop. La publication sur npm, elle, n'a rien.

Ce qui s'est passé. Deux tentatives perdues dans la même journée. La première parce que la version du paquet n'avait pas bougé : npm publish refuse une version déjà en ligne, et il le refuse après la construction, après l'authentification, et après qu'on ait cru la publication faite. La seconde parce que la commande a été lancée depuis une branche de travail, ce que rien ne signale non plus. S'y ajoute la double authentification, qui redemande le navigateur à chaque essai et transforme une erreur d'une seconde en trois minutes.

Ce qui rend ce défaut différent des précédents. F-001, F-011, F-012 et F-015 décrivent des contrôles qui rendent un verdict favorable sans avoir rien vérifié. Celui-ci est l'inverse : il n'y a pas de contrôle du tout. Le geste sort du dépôt, donc de tout ce que la CI peut atteindre, et personne ne s'en aperçoit tant qu'il n'échoue pas.

Ce qu'on en a fait. Un garde de version en CI compare chaque dossier de packages/ à la branche de destination, et fait échouer la pull request si le code d'un paquet change sans que sa version bouge. Il n'exige rien quand seuls le README, la licence ou les tests changent : republier pour une faute d'orthographe n'a pas de sens.

Ce garde attrape la première des deux causes, celle qui se répète. La seconde, publier depuis la mauvaise branche, reste entre les mains de celui qui publie : la CI ne voit pas ce qui se passe sur un poste.

La leçon. Un projet finit par ne vérifier que ce qu'il sait vérifier, et ses angles morts se logent aux endroits où son outillage n'a pas de prise. Ici la frontière est nette : ce qui vit dans le dépôt est couvert, ce qui en sort ne l'est pas. Le garde ne supprime pas cette frontière, il la déplace d'un cran, en refusant en amont l'état qui aurait fait échouer la publication en aval.

C'est aussi la raison pour laquelle un CHANGELOG.md tenu à jour vaut mieux qu'une automatisation partielle : il oblige à formuler ce qui change, et c'est ce moment de formulation qui fait remarquer qu'une version n'a pas bougé.

F-017 : trois façons pour un audit de parler de ce qu'il n'a pas regardé

Le contexte. npx maedow-arch check sert à décider s'il vaut la peine d'adopter le standard sur un projet déjà écrit. Un rapport faux ne coûte pas un bug : il coûte la décision.

Ce qui s'est passé. Trois défauts du même genre, trouvés à trois semaines d'intervalle, tous en retournant l'outil sur ce dépôt.

Le premier : l'audit signalait public/mockServiceWorker.js, un fichier généré, comme une violation. Il partait d'une liste d'exclusions, examinait tout sauf ce dont on s'était souvenu d'écarter, et aurait signalé demain scripts/, e2e/ ou le prochain dossier imprévu.

Le deuxième : appliqué à la racine de ce dépôt, il annonçait trois violations TS-STRICT sur un tsconfig.json qui n'existe pas. tsconfig?.compilerOptions ?? {} transformait un fichier absent en fichier vide, donc en trois options manquantes. Un projet purement JavaScript recevait l'ordre de corriger un fichier qu'il n'a pas, en tête de son plan de migration.

Le troisième : le rapport affirmait « ce projet n'a pas de features, les règles de frontière n'ont donc rien pu vérifier » sur tous les projets, y compris trois lignes sous des violations MA-002 et MA-003 que ces mêmes règles venaient de trouver. classer rend la couche feature au singulier, le rapport cherchait le dossier features au pluriel, et la chaîne ne correspondait jamais.

Ce qui les relie. F-001, F-011, F-012 et F-015 décrivent des contrôles qui rendent un verdict favorable sans avoir rien vérifié. F-016 décrit l'absence de tout contrôle. Ceux-ci forment une troisième famille : le contrôle parle de ce qu'il n'a pas regardé. Il accuse un fichier hors périmètre, un fichier absent, ou nie avoir vu ce qu'il vient de trouver.

Le premier défaut est celui que le corpus décrit lui-même à propos du DTO de sortie : une liste d'exclusions est en retard d'un champ en permanence, et un Omit ne rattrape jamais le champ ajouté ce matin. L'outil faisait exactement ce que sa propre règle défend.

Ce qu'on en a fait. L'audit part désormais d'une liste blanche : seuls les fichiers appartenant à une couche du standard sont examinés, le reste est dénombré et rapporté à part. Cette liste ne se périme pas, puisque c'est celle que le corpus énonce. La liste de dossiers ignorés subsiste pour ne pas descendre dans node_modules, ce qui est une question de coût et non de périmètre.

TS-STRICT ne produit plus rien sans tsconfig.json, et le rapport distingue deux silences : un projet sans TypeScript, pour qui la règle est hors sujet, et un projet qui a des .ts sans configuration, ce qui est un problème en soi. Le troisième défaut se corrige en comparant les couches et non leurs noms de dossiers.

La leçon. Un contrôle qui se trompe de périmètre est plus coûteux qu'un contrôle absent, parce qu'il inspire confiance. Les trois défauts ont survécu à la relecture et aux tests : ils ne sont apparus qu'en lançant l'outil sur ce dépôt, c'est-à-dire sur un projet que ses auteurs connaissent assez pour voir que la réponse est fausse.

C'est l'argument le plus concret en faveur du dogfooding : une fixture prouve qu'un outil trouve ce qu'on y a mis, elle ne prouve jamais qu'il ne trouve rien d'autre.

F-018 : une règle qui s'éteint quand on compose les entrées

Le contexte. eslint-config-maedow-arch publie deux entrées, et le corpus prescrit de les charger l'une après l'autre. Le banc de test éprouvait chacune séparément : la fixture invalide sous l'entrée par défaut, la fixture stricte sous l'entrée stricte. Jamais leur composition, qui est pourtant la seule configuration que les projets emploient.

Ce qui s'est passé. En configuration plate, deux objets qui ciblent le même fichier et déclarent la même règle ne fusionnent pas leurs options : la dernière déclaration remplace la précédente en entier. L'entrée par défaut déclarait no-restricted-syntax sur core pour interdire le JSX, l'entrée stricte la redéclarait sur tous les fichiers TypeScript pour interdire la double assertion. Pour un fichier .tsx de core, la seconde arrivait après et effaçait la première.

Sous la composition prescrite, MA-004 ne refusait donc plus le JSX dans le domaine. no-restricted-imports restait actif, mais l'entrée par défaut explique elle-même pourquoi il ne suffit pas : le runtime JSX automatique n'exige aucun import de React, et cette règle seule « aurait donné l'illusion d'une protection ».

Ce qui rend ce défaut différent des précédents. F-001, F-011, F-012 et F-015 décrivent des contrôles qui rendent un verdict favorable sans avoir rien vérifié. F-016 décrit l'absence de contrôle, F-017 les contrôles qui parlent de ce qu'ils n'ont pas regardé. Celui-ci ajoute une quatrième forme : chaque pièce fonctionne, et c'est leur assemblage qui casse. Aucune relecture d'un fichier isolé ne pouvait le voir, et le banc validait honnêtement ce qu'il testait.

Le détail qui pique : strict.js porte en tête un commentaire qui cite F-001. Le fichier qui nomme le défaut le réintroduisait.

Ce qu'on en a fait. Un dernier bloc dans strict.js redéclare les trois sélecteurs ensemble pour core, seule composition qui préserve les deux intentions. Et surtout, le banc éprouve désormais la fixture invalide sous la composition en plus de l'entrée seule : retirer le bloc fait échouer ce test, vérifié en le retirant.

Comment il a été trouvé. Pas par nous. Par le projet ABBA, dix-neuf lots d'usage réel, qui l'a diagnostiqué en lisant nos sources plutôt qu'en tâtonnant, et qui note que ce sont nos commentaires expliquant le pourquoi qui l'ont rendu trouvable. Le relevé complet vit dans feedback/.

La leçon. Un paquet qui publie plusieurs entrées compose une surface qu'aucune de ses entrées ne décrit. Tester les entrées ne suffit pas : il faut tester la configuration que la documentation prescrit, parce que c'est la seule qui existe chez les utilisateurs.

Et une leçon sur le reste du lot : trois autres défauts remontés au même moment, R-003, R-005 et R-010, partagent un trait avec celui-ci. Ils sont silencieux. Aucun ne produit d'erreur, tous laissent lint, typecheck, build et audit au vert. Un projet généré recevait Tailwind sans que la feuille soit importée, un socle sans MA-005 à MA-007, un helper sans son test. Ce qu'un standard vérifie mécaniquement ne dit rien de ce qu'il livre.

F-019 : onze violations annoncées, onze fausses, et la confiance perdue

Le contexte. maedow-arch check cherchait du JSX dans core/ par expression régulière, sur le texte du fichier, quelle que soit son extension. Un commentaire au-dessus affirmait écarter les génériques TypeScript.

Ce qui s'est passé. Il ne les écartait pas. Promise<UserEntity>, Array<string>, Result<T> : tout générique à un seul argument était signalé « contient du JSX ». Une balise écrite dans un littéral de chaîne aussi. Or la couche visée est exactement celle où vivent les génériques, les dépôts et les validateurs, et Promise<UserEntity | null> est la signature que le corpus donne lui-même en exemple. L'audit officiel signalait l'exemple officiel.

Le projet ABBA a mesuré l'accumulation sur dix-neuf lots : quatre violations MA-004 annoncées, puis huit, puis onze. Les onze fausses, find src/core -name "*.tsx" rendant zéro fichier. Onze lignes rouges en tête d'un rapport dont l'ordre de migration dit « commencer par sortir l'interface de core ».

Ce qui rend ce défaut différent des précédents. Il ne fait pas perdre du temps, il fait perdre la confiance. L'équipe a dû inscrire une décision de projet pour dire que le critère de vérité est npx eslint et non le compteur de l'audit. Un standard ne devrait pas avoir besoin qu'un projet écrive cela.

F-017 décrivait un audit qui parle de ce qu'il n'a pas regardé. Celui-ci en est la suite logique : il regarde, et il se trompe, ce qui est pire, parce qu'un faux positif répété apprend à ignorer l'outil.

Ce qu'on en a fait. La détection ne s'applique plus qu'aux .tsx et .jsx. Le registre portait déjà la justification, à MA-004 : « un .ts ne peut pas en contenir, le parser le refuserait avant nous ». Aucune détection n'est perdue, et la classe entière de faux positifs disparaît sans qu'on ait à distinguer une balise d'un paramètre de type, ce qu'aucune expression régulière ne sait faire sur du texte.

La leçon. Un commentaire qui affirme une propriété ne la crée pas. Celui-ci disait « on écarte les génériques TypeScript » et il a survécu à la relecture précisément parce qu'il rassurait. La fixture qui l'aurait démenti n'existait pas : les fixtures couvraient ce que la règle doit trouver, jamais ce qu'elle ne doit pas trouver.

C'est aussi pourquoi le même lot ajoute un renseignement plutôt qu'une règle. shadcn init dépose ses hooks dans src/hooks/, qui n'est aucune des cinq couches : ces fichiers échappent à l'audit et aux frontières à la fois, et rien ne l'annonçait. Le rapport le dit désormais, sans le compter comme une violation, parce qu'aucune règle du registre ne le prévoit et qu'un outil n'invente pas de règle.

F-020 : deux plugins, deux réglages de résolveur, et une règle qui s'éteint

Le contexte. L'entrée stricte porte MA-007 par eslint-plugin-import, plafonné à ESLint 9 et sans aucune préversion pour la 10 sur 132 versions publiées. Tout projet appliquant strict était donc bloqué sur une version qu'ESLint lui-même classe en maintenance. Le remplacement par eslint-plugin-import-x, son fork maintenu, tenait en un nom de paquet et un préfixe de règle.

Ce qui s'est passé. Presque. En renommant import/resolver en import-x/resolver dans le banc de test, MA-001 a cessé d'être détectée sur l'import par alias. Aucune erreur, aucun avertissement : eslint-plugin-boundaries lit son résolveur sous import/resolver, eslint-plugin-import-x sous import-x/resolver, et les deux préfixes ne se replient pas l'un sur l'autre.

Le renommage paraissait mécanique, il ne l'était pas : deux plugins distincts partageaient un réglage dont le nom appartenait historiquement au premier.

Ce qui l'a attrapé. Le test de composition ajouté trois lots plus tôt, en réponse à F-018. Il éprouve la fixture invalide sous l'entrée par défaut et sous la composition prescrite, et c'est la seconde qui a signalé la disparition :

✗ MA-001 disparaît dans core/pricing/viaAlias.ts quand l'entrée stricte est chargée.

Sans lui, le lot serait passé au vert. Les trois autres cas de MA-001 continuaient de remonter, seul l'import par alias tombait, et c'est précisément la fixture ajoutée un jour parce que rien ne garantissait que les contournements de chemin relatif soient interceptés.

Ce qu'on en a fait. Le banc déclare les deux réglages, avec le commentaire qui dit pourquoi. La configuration publiée les déclarait déjà chacun de son côté, l'entrée par défaut pour boundaries et l'entrée stricte pour import-x : aucun projet installé n'était exposé, mais rien ne l'avait vérifié.

La leçon. Un test écrit pour une friction en attrape une autre, deux lots plus tard, sur un chemin que personne n'avait relié au premier. C'est l'argument le plus concret pour écrire le test au moment où l'on comprend le défaut, plutôt que de se contenter du correctif : le correctif règle un cas, le test surveille une classe.

Et une note sur la dette héritée : ce lot ne monte aucune version d'ESLint, il lève seulement le plafond. eslint-plugin-import avait cessé de suivre son écosystème, et le standard qui déléguait une garantie à ce plugin héritait de son immobilité. Le corpus dit désormais quel plugin porte quelle règle, pour que ce risque se suive au lieu de se découvrir un jour d'ERESOLVE.

F-021 : le standard hérite du calendrier de ses plugins, une seconde fois

Le contexte. F-020 venait de lever un plafond : eslint-plugin-import bloquait les projets sur ESLint 9. En montant les majeures dépassées du scaffold, la même forme est réapparue ailleurs.

Ce qui s'est passé. TypeScript 7 est publié, et le scaffold épinglait encore la 5. La montée paraissait libre. Elle ne l'est pas : typescript-eslint déclare typescript: >=4.8.4 <6.1.0. Passer en 7 casse le lint de tout projet généré, c'est-à-dire les sept règles vérifiées par la machine.

Le contrôle a été fait avant d'écrire la version, et non après un échec de la matrice. C'est la seule différence avec la fois précédente, où vitest@4 avait fait tomber seize jobs.

Ce qu'on en a fait. Quatre majeures sur cinq sont montées : ESLint 10, Next 16, @types/node 26 et vitest 5. TypeScript reste en 5, et le corpus dit pourquoi plutôt que de laisser croire à un oubli.

Ce que cela dit du standard. Un standard qui délègue ses garanties à des plugins hérite de leur calendrier, et ce n'est pas un accident : c'est le prix de la vérification par la machine, que Maedow Arch revendique. Deux plafonds en deux lots, portés par deux paquets différents, sur deux dimensions différentes de la même chaîne.

La conséquence pratique est qu'une montée de majeure ne se décide pas en lisant le registre npm. Elle se décide en lisant les peerDependencies de ce qui vérifie les règles, et ce contrôle vaut d'être fait à chaque fois.

Au passage, le piège que le projet ABBA avait documenté. Leur entrée N-002 décrivait un premier build Next 16 qui réécrit tsconfig.json en cours d'exécution puis échoue, le second passant. Le remède est de livrer d'avance les valeurs que Next impose : jsx en react-jsx, et l'entrée .next/dev/types dans include. Vérifié par empreinte sur un projet neuf, le fichier n'est plus touché et aucun message de reconfiguration n'apparaît.

F-022 : la matrice éprouve le dépôt, pas ce que npm résout

Le contexte. Vingt jobs génèrent un projet, l'installent, le lintent et le construisent. Pour éprouver la configuration ESLint locale plutôt que celle du registre, ils l'empaquettent et substituent un chemin file: dans le package.json du projet généré. C'est délibéré, et c'est écrit dans le workflow : un tarball reproduit ce qu'un utilisateur reçoit, là où un lien file: par symlink ferait chercher les dépendances de pair au mauvais endroit.

Ce qui s'est passé. La substitution écrase la contrainte de version. La matrice ne lit donc jamais ce que le fragment épingle, et un écart entre cette contrainte et la version publiée lui est invisible.

Quand la configuration est passée en 0.4.0, en remplaçant eslint-plugin-import par import-x, le fragment est resté en ^0.3.0. En 0.x, l'accent circonflexe ne franchit pas la mineure : ^0.3.0 couvre 0.3.x et exclut la 0.4.0. Un projet généré depuis npm recevait donc la 0.3.1, qui importe l'ancien plugin, pendant que le fragment installait le nouveau.

Cannot find package 'eslint-plugin-import' imported from
node_modules/eslint-config-maedow-arch/strict.js

L'entrée stricte ne se chargeait plus, donc MA-005, MA-006 et MA-007 disparaissaient. Et rien ne le signalait : la configuration générée ne charge que l'entrée par défaut, si bien que lint, typecheck et build restaient au vert.

Comment il a été trouvé. En jouant le trajet d'un lecteur avant une annonce publique : npx create-maedow-arch-app depuis le registre, puis l'installation, le lint et le build. C'est le seul chemin qui ne passe pas par le dépôt, et c'est exactement celui que la matrice ne peut pas emprunter.

Ce qu'on en a fait. Le fragment passe en ^0.4.0, et un test compare les deux sources de vérité du dépôt, la contrainte du fragment et la version de la configuration, sans réseau. Il échoue si l'une avance sans l'autre, vérifié en remettant la valeur fautive.

La leçon. Un banc d'essai qui substitue une dépendance cesse d'éprouver la façon dont elle est déclarée. C'est le prix d'un choix par ailleurs justifié, et il se paie à l'endroit exact que la substitution rend aveugle. Ce dépôt en compte trois du même genre en un jour, tous là où la vérification ne pouvait pas atteindre : le lien mort qu'aucun build ne suit, la page dérivée qu'aucune règle n'ignorait, et cette contrainte qu'aucun job ne lit.

F-023 : trois causes, un seul symptôme, et la troisième était dans le fichier généré

Le contexte. Un projet généré en mode Full doit charger les deux entrées de la configuration ESLint : la défaut pour les frontières, la stricte pour la discipline de typage. Le corpus le prescrit, le registre associe MA-005, MA-006 et MA-007 à la seconde, et le champ exports du paquet la déclare.

Ce qui s'est passé. Le eslint.config.mjs généré n'importait que l'entrée par défaut. Pas d'erreur, pas d'avertissement : le fichier est valide, le plugin est installé, l'entrée existe et n'est simplement jamais chargée.

C'est la troisième cause du même symptôme en une semaine, après R-003 et F-022. La première portait sur la version épinglée, la deuxième sur une plage qui ne franchit pas la mineure en 0.x, celle-ci sur le fragment de configuration lui-même. Les deux correctifs précédents étaient bons, et le résultat restait identique : un projet généré selon la documentation obtenait un socle sans trois des neuf règles, et le lint restait vert.

La distinction qui change le diagnostic. Le projet ABBA a mesuré les deux cas séparément, et ils ne se comportent pas pareil :

SituationCe qui se passe
Entrée stricte importée, plugin absentERR_MODULE_NOT_FOUND, code de sortie 2, le lint entier s'arrête
Entrée stricte jamais importéerien n'échoue, le lint est vert, trois règles manquent

Le premier cas est bruyant, donc sans danger. Le second est celui que produisait le fragment, et c'est le mauvais des deux. Un import qui casse se voit ; un import absent ne se voit pas.

Comment il a été trouvé, et pourquoi pas par nous. Nous avions vérifié les trois règles strictes sur un projet installé depuis npm, avec trois sondes fautives et trois remontées. Mais la configuration que nous avions sondée, nous l'avions écrite pour l'occasion. Le générateur n'en produit pas de semblable, et c'est lui qu'il fallait éprouver.

ABBA a fait la même mesure sur le fichier généré tel quel : une remontée sur trois. Puis a ajouté la seule ligne manquante : trois sur trois. La différence tenait entièrement à cette ligne.

Ce qu'on en a fait. Le mode Full livre sa propre configuration, qui charge les deux entrées dans l'ordre prescrit. Le profil Light garde la sienne, et dit désormais pourquoi l'entrée stricte n'y est pas, avec les deux lignes pour l'ajouter : une absence expliquée est un choix, une absence muette est un oubli, et rien ne les distingue dans un fichier généré.

Deux tests sondent le fragment lui-même plutôt qu'une configuration écrite pour le test.

La leçon. Vérifier qu'une configuration se charge ne prouve rien ; il faut faire tirer chaque règle sur une faute écrite exprès, et sur l'artefact que l'utilisateur reçoit, pas sur un équivalent. Cette nuance-là nous a échappé alors même que nous croyions l'appliquer : nous avons sondé notre propre montage en pensant sonder le produit.

Sur cette page

F-001 : une config de frontières qui passe au vert sans rien vérifierF-002 : features/_shared classé comme une feature ordinaireF-003 : un projet fraîchement scaffoldé ne démarrait pasF-004 : npm supprime les .gitignore des packages publiésF-005 : une duplication manuelle qui avait déjà divergéF-006 : deux paquets d'un même framework qui dérivent sous ^F-007 : npm refuse désormais toute publication sans 2FAF-008 : Vercel ne voit pas un dépôt fraîchement transféréF-009 : baseUrl déprécié, remonté par un vrai projetF-010 : le risque pnpm, anticipé puis démentiF-011 : le risque de résolution s'est produit, mais pas là où je l'attendaisF-012 : un contrôle qui ne voyait pas ce qu'il prétendait garantirF-013 : deux utilitaires Tailwind de même spécificité, et la bordure disparaîtF-014 : le nettoyage des tirets cadratins avait cassé deux fichiers YAMLF-015 : une règle active, chargée sans erreur, et qui ne regardait rienF-016 : le seul geste du projet qui n'a aucun filetF-017 : trois façons pour un audit de parler de ce qu'il n'a pas regardéF-018 : une règle qui s'éteint quand on compose les entréesF-019 : onze violations annoncées, onze fausses, et la confiance perdueF-020 : deux plugins, deux réglages de résolveur, et une règle qui s'éteintF-021 : le standard hérite du calendrier de ses plugins, une seconde foisF-022 : la matrice éprouve le dépôt, pas ce que npm résoutF-023 : trois causes, un seul symptôme, et la troisième était dans le fichier généré