Files
_Assistant_Lead_Tech/90_debug_et_postmortem.md
T
MaksTinyWorkshop 2a06429898 docs(knowledge): capitalisation racine — post-mortems (90_) et ADR (40_) du triage local
Intégration des propositions ciblant les fichiers racine validés.

90_debug_et_postmortem.md (9 post-mortems) :
- type métier dupliqué dans package shared + bridge ; flakiness "socket hang up" e2e
  (recyclage de port éphémère) ; rtk masque la sortie des CLI build/test ; vi.spyOn sur
  module ESM ; `as const` → TS2769 (jose) ; échec massif suite = template DB corrompu ;
  séparer 2 chantiers mélangés (barrel partagé) ; modèle Prisma fantôme (migration sans
  model) + variante effet iceberg CI

40_decisions_et_archi.md (4 ADR) :
- CI e2e mobile pas un prérequis prod ; vérifier le modèle de données réel avant spec ;
  segmenter l'auth par sensibilité d'action ; IdP Keycloak auth-only + RBAC local (Proposed)

Dédupliqué vs knowledge/ déjà écrit : les ADR/post-mortems apportent l'angle narratif/décisionnel
(le "pourquoi"/"récit de debug"), complémentaire des règles réutilisables en knowledge/, avec
cross-références. Blocs déjà couverts ailleurs (113 liste/détail) non réintégrés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:58:46 +02:00

33 KiB
Raw Blame History

Debug & post-mortems

Ce fichier sert à capitaliser sur les problèmes rencontrés.

À documenter

  • bug pénible
  • mauvaise compréhension
  • fausse hypothèse
  • solution finale

Objectif

Ne plus jamais perdre du temps sur le même problème.


Postmortems

SQL Server qui crash dans un conteneur LXC Proxmox

Contexte

NUC personnel sous Proxmox avec plusieurs services en conteneurs LXC. Un conteneur SQL Server (Microsoft SQL Server Linux) ne démarrait plus.

Symptômes

  • sqlcmd impossible → timeout
  • service mssql-server en boucle de restart
  • logs contenant :
Operation not permitted
chmod: changing permissions of '/var/opt/mssql/log/...'
  • crash + génération de core dump

Cause probable

SQL Server utilise certaines opérations système qui sont mal supportées dans les conteneurs LXC (permissions, filesystem, capabilities).

Dans un environnement Proxmox LXC, cela peut casser après :

  • une mise à jour
  • un changement de permissions
  • un changement de configuration du conteneur

Conclusion

SQL Server n'est pas un bon candidat pour un conteneur LXC Proxmox.

Décision architecturale

Pour un homelab ou un petit serveur :

  • éviter SQL Server en LXC
  • préférer :
    • PostgreSQL
    • MariaDB / MySQL

Si SQL Server est nécessaire :

  • utiliser une VM complète plutôt qu'un conteneur.

Règle à retenir

Éviter les bases lourdes nécessitant des capabilities système avancées dans des conteneurs LXC.


Suppression silencieuse due à deux éditions concurrentes sur le même fichier

Contexte

Un même fichier a été modifié par deux mécanismes proches dans le temps : édition en cours dagent et passe outillée/linter/formatteur.

Symptômes

  • bloc de code disparu sans erreur explicite
  • diff final incohérent avec lintention de modification
  • impression de “régression fantôme” après une édition pourtant correcte

Cause probable

Deux processus ont réécrit le même fichier sans coordination, le second écrasant silencieusement une partie du travail du premier.

Correctif / règle à retenir

  • éviter deux passes d’écriture concurrentes sur le même fichier
  • relire le diff immédiatement après toute passe automatique
  • privilégier une séquence stricte : édition, puis lint/format, puis vérification

tsx + NestJS : injection par type cassée silencieusement

Contexte

Projet app-alexandrie, Epic 3, le 10-03-2026. Le backend NestJS tournait avec tsx watch dans un contexte ESM (module: nodenext), notamment pour rester compatible avec Prisma v7.

Symptômes

  • TypeError: Cannot read properties of undefined (reading 'get') dans le constructeur dun service
  • ConfigService injecté par type mais undefined au runtime
  • @Injectable() et ConfigModule correctement configurés, sans erreur de compilation

Cause probable

tsx repose sur esbuild pour transpiler TypeScript. Dans ce contexte, emitDecoratorMetadata est ignoré même sil est activé dans tsconfig.json. NestJS ne peut donc plus résoudre correctement certaines injections par type, notamment constructor(private readonly config: ConfigService).

Correctif / règle à retenir

  • ne pas supposer que emitDecoratorMetadata fonctionne avec tsx
  • dans ce contexte, éviter linjection par type de ConfigService pour les services dinfra
  • lire explicitement les variables via process.env, après chargement amont de ConfigModule.forRoot()

Exemple :

// AVANT
constructor(private readonly config: ConfigService) {
  const host = this.config.get('REDIS_HOST');
}

// APRES
constructor() {
  const host = process.env['REDIS_HOST'] ?? 'localhost';
}

Alternative écartée

nest start --watch a été testé mais a introduit des conflits ESM/CJS dans ce contexte (exports is not defined).


export { fn } ne constitue pas un import local — détecté uniquement au build

Contexte

Projet app-template-resto, story 2-4, le 17-03-2026. Dans getPublicHomeData.ts, la fonction resolvePublicTenantSelection avait été déplacée dans src/server/tenant/resolvePublicTenant.ts et re-exportée depuis l'ancien emplacement.

Symptômes

  • Cannot find name 'resolvePublicTenantSelection' au next build uniquement
  • ESLint et tsc (hors build) ne signalaient rien
  • La fonction était utilisée localement dans le même fichier qui la re-exportait

Cause

// getPublicHomeData.ts
export { resolvePublicTenantSelection } from "@/server/tenant/resolvePublicTenant";

// puis, plus bas dans le même fichier :
const result = resolvePublicTenantSelection(env); // ← NameError au build

Un re-export (export { fn } from "...") ne crée pas de binding local dans le fichier. La fonction est exportée vers l'extérieur mais n'est pas disponible comme variable locale.

Correctif / règle à retenir

Si une fonction est utilisée dans le même fichier qui la re-exporte, ajouter un import séparé en plus du export :

import { resolvePublicTenantSelection } from "@/server/tenant/resolvePublicTenant";
export { resolvePublicTenantSelection }; // pour les appelants externes

CLI npm globale qui ne se met pas à jour (prefix / permissions / contexte projet)

Contexte

Mise à jour de @openai/codex via la CLI (codex update), sur une machine avec installation npm globale utilisateur (~/.npm-global) et exécution depuis un repo contenant un .npmrc non standard.

Symptômes

  • Message dupdate CLI affiché mais version inchangée après npm install -g
  • codex --version reste sur une ancienne version
  • Installation via sudo ne change rien
  • which codex et npm root -g pointent vers des chemins différents

Cause

  • Décalage entre :
    • le prefix npm utilisé pour installer
    • le binaire exécuté
  • Ancienne installation toujours active dans le bon prefix utilisateur
  • Contexte projet (.npmrc) pouvant influencer le comportement de npm

Correctif / règle à retenir

  • Ne jamais utiliser sudo npm install -g
  • Sassurer que :
    • npm config get prefix = dossier utilisateur (ex : ~/.npm-global)
    • which <cli> pointe vers ce même prefix
  • Faire les installs globales hors dun repo (éviter .npmrc projet)
  • En cas de doute, nettoyer :
rm -rf ~/.npm-global/lib/node_modules/<package>
rm -f ~/.npm-global/bin/<cli>
npm install -g <package>@latest

Commandes de diagnostic utiles

  • npm config get prefix
  • which <cli>
  • npm root -g
  • npm ls -g --depth=0 <package> | npm list -g @openai/codex --depth=0
  • --version

Sub-agents Claude Code — Write indisponible dans la sandbox Explore

Contexte

Workflow BMAD testarch-test-review sur RL799_V2 (24-04-2026) utilisant 4 sub-agents subagent_type=Explore pour évaluer 4 dimensions qualité en parallèle. Chaque sub-agent devait écrire un fichier JSON dans /tmp/.

Symptômes

  • Les 4 sub-agents ont terminé leur analyse avec succès mais aucun n'a réussi à écrire son fichier JSON
  • Messages de retour : "Je rencontre une limitation d'outillage… je suis en mode READ-ONLY… je génère le rapport directement en texte."

Cause

Le sub-agent type Explore n'a pas accès à l'outil Write dans sa sandbox (spec : "Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit"). Non documenté clairement dans les workflows TEA qui demandent pourtant d'écrire en JSON.

Correctif / règle à retenir

  1. Ne pas demander aux sub-agents Explore d'utiliser Write — briefer explicitement "retourne le JSON en bloc dans ta réponse finale"
  2. L'orchestrateur matérialise les fichiers de sortie pour le compte des sub-agents
  3. Alternative : utiliser subagent_type=general-purpose qui a accès à tous les tools (mais plus cher en tokens et moins spécialisé pour l'exploration)

Extrait de brief corrigé pour futur usage :

Ta mission : analyse X dans les fichiers Y.
Format de sortie : JSON structuré selon le schéma ci-dessous.
IMPORTANT : retourne le JSON directement dans ta réponse finale, entre blocs ```json```.
Ne tente pas d'écrire de fichier (Write indisponible dans ta sandbox).
L'orchestrateur matérialisera le fichier à partir de ton retour.

Effet iceberg en CI — patcher en cascade jusqu'au fond du puits

Contexte

Quand un fix CI structurant rétablit un pipeline qui foirait depuis longtemps, plusieurs bugs latents en aval peuvent apparaître en cascade : ils étaient tous présents avant, juste invisibles parce que le runner s'arrêtait à l'échec amont. Vécu sur RL799_V2 le 30-04 / 01-05-2026, 8 étages d'iceberg fixés en cascade.

Symptômes

# Phase Symptôme Cause Fix
1 CI tests Cannot find module '@org/shared' dist/lib non bâti avant test:api Build workspace en amont
2 CI tests Module '@prisma/client' has no exported member 'X' Client Prisma non généré Inverser prisma generatepnpm build
3 CI tests Seed incomplet : 0 users / N attendus Étape seed manquante Ajouter prisma db seed après prisma migrate deploy
4 CI tests <env> non configuré (requis hors dev) Variable d'env applicative manquante en CI Définir au bloc env: du job
5 CI tests 14×500 sur endpoints qui chiffrent ENCRYPTION_KEY manquante Idem
6 CI tests (PDF) Could not find Chrome Puppeteer cherche son cache local absent du runner PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
7 CD prod (migrate) Cannot find module '/app/scripts/check-node-version.mjs' pnpm run prisma:migrate appelle un script absent de l'image API Appel direct du binaire Prisma
8 CI tests Test attend 50,00 € reçoit 1,19 € waitForNotification mal scopé (filtre par type mais pas par recipientId) — masquée par les étages 1-7 Re-run OU patch chirurgical du where:

Chaque étage masquait le suivant. Aucun n'était nouveau — tous présents avant la session, mais invisibles à cause des étages amont.

Cause

  • Local ≠ CI : en local, dist/ traîne, le client Prisma est généré, la DB est seedée d'une session précédente, le .env est complet. Le bug est invisible
  • Pipeline early-exit : un échec à l'étape N ne laisse rien tourner aux étapes N+1, N+2, …
  • Effet additif des sessions : plus le pipeline est cassé depuis longtemps, plus le code applicatif a évolué sans validation CI

Correctif / règle à retenir

  1. Validation locale stricte avant push CI structurant : simuler les conditions CI vierges (rm -rf node_modules/.prisma packages/*/dist apps/*/.next + relancer la chaîne complète)
  2. Lecture honnête des nouveaux failures : après un fix CI structurant, ne pas présumer que les nouveaux failures sont des régressions du fix. Probablement des bugs latents
  3. Tableau iceberg : noter au fil de la session le tableau (étage / symptôme / cause / fix). Ne pas se laisser submerger par "ça casse encore"
  4. Push après chaque étage : ne pas attendre d'avoir tout fixé. Chaque fix structurant mérite son commit thématique
  5. Ne pas stopper trop tôt : un seul push ne révèle qu'un étage. Tant qu'il y a des bugs latents, le pipeline cassera

Signal pour repérer un effet iceberg

  • Le pipeline était cassé depuis ≥ 1 semaine
  • Le fix d'aujourd'hui touche une étape précoce du workflow (install, build, generate, migrate)
  • Les commits récents ont ajouté des features sans valider en CI
  • Sentiment vague de "ça pourrait casser plein d'autres trucs" — c'est probablement vrai

Prisma migrate inclut les diffs cosmétiques (RenameIndex)

Contexte

prisma migrate dev --create-only --name add_lodge_settings peut générer une migration qui contient (1) le changement attendu mais aussi (2) un side-effect cosmétique pré-existant entre le schema Prisma et la DB qui n'avait jamais été nettoyé. RL799_V2 — migration 20260427120920_add_lodge_settings qui ramassait un ALTER INDEX … RENAME TO … orphelin.

Symptômes

  • Migration thématique qui contient un rename d'index sans rapport avec le scope de la story
  • Un dev qui regarde la migration ne comprend pas pourquoi cet ALTER INDEX est là

Options et décision

Option Pro Con
Garder le rename dans la migration thématique avec commentaire la prochaine prisma migrate dev ne re-générera pas ce rename le commit "thématique" contient un side-effect cosmétique
Retirer le rename commit propre la prochaine migration thématique l'inclura à nouveau → piège pour le prochain dev
Migration de cleanup séparée plus propre nécessite 2 migrations + 2 PRs

Décision recommandée : option 1 avec commentaire explicite dans le .sql :

-- RenameIndex (réalignement DB ↔ schema, dérive cosmétique pré-existante)
ALTER INDEX "tronc_entries_tenue_idx" RENAME TO "tronc_entries_tenue_id_idx";

Correctif / règle à retenir

  • Préventif : prisma migrate diff régulièrement (CI/CD ou pré-commit) pour détecter la dérive AVANT qu'elle ne pollue une migration thématique
  • Curatif : inspecter manuellement le SQL généré par --create-only avant de l'appliquer en migration thématique

Réseau Docker partagé entre stacks supprimé par compose down malgré external: true

Contexte

NUC sous Proxmox, le 22-04-2026. Plusieurs stacks Docker partagent un réseau commun : un Postgres mutualisé (shared-postgres) auquel se connectent plusieurs apps via leur IP Tailscale. Chaque stack déclare le réseau en external: true dans son docker-compose.yml. Après une coupure de courant et redémarrage de la stack, le conteneur Postgres tournait en Up (healthy) mais plus aucune app ne pouvait s'y connecter.

Symptômes

  • Conteneur shared-postgres en Up (healthy), mais docker port <container> retourne vide et docker inspect ... NetworkSettings.Ports retourne {}.
  • Les autres stacks connectées au réseau ont perdu toute connectivité.

Cause

Double piège :

  1. external: true ne protège pas toujours contre la destruction. Un docker compose down exécuté plus tôt a supprimé le réseau shared-postgres-net malgré son external: true, parce qu'aucun autre projet ne le "possédait" au moment de la commande. Le flag external protège contre la création, pas toujours contre la destruction — surtout quand le réseau a initialement été créé par un compose up (il porte alors les labels Compose et appartient au projet).
  2. Config figée à la création. Le conteneur relancé par restart: unless-stopped garde sa config figée au moment de sa création : les modifications ultérieures du docker-compose.yml (ajout du bloc ports:, changement de réseau) ne sont pas prises en compte tant qu'on ne fait pas up --force-recreate. Le conteneur s'est donc recréé sans réseau ni mapping de ports.

Correctif / règle à retenir

Un réseau Docker partagé entre plusieurs stacks doit être créé explicitement hors compose, pas par une stack.

docker network create shared-<nom>-net

Le réseau n'a alors aucun label Compose, donc aucun compose down ne peut le supprimer.

Figer le nom de projet Compose. Par défaut, le nom de projet Compose = nom du dossier. Si le container_name diffère du nom de dossier (ex. dossier postgres/ mais container_name: shared-postgres), un compose down peut "ne rien voir à supprimer" car il cherche dans le mauvais projet. Solution : déclarer name: au top-level :

name: shared-postgres

services:
  postgres:
    container_name: shared-postgres
    ...

Règles opérationnelles pour les stacks partagées critiques :

  • Ne jamais faire docker compose down sur la stack qui "porte" le réseau partagé.
  • Utiliser stop / start / up -d --force-recreate pour les maintenances.
  • Documenter la procédure de recréation du réseau dans le README de la stack.

Signal de détection

Conteneur Up (healthy) mais docker port <container> vide, ou docker inspect ... NetworkSettings.Ports à {} → le conteneur a été recréé sans sa config de ports à jour. Fix : compose up -d --force-recreate (après avoir vérifié que le réseau externe existe).

Risque connexe côté DNS Docker : voir knowledge/infra/risques/docker.md.


Type métier dupliqué dans un package shared avec bridge de conversion

Contexte

Projet RL799_V2, chantier refactor/document-types-fusion (commit 1a0398a), le 06-05-2026. Audit cartographique du package @rl799/shared : le concept DocumentType existait en deux définitions concurrentes avec des valeurs différentes, reliées par un bridge de conversion v1ToDbType.

Symptômes

  • Deux types nommés DocumentType à la racine de fichiers distincts du package partagé :
    • dto/DocumentType.ts exporte une union V1 ('planches' | 'planches-tracees' | …, pluriel/kebab-case, valeurs naturelles pour les URLs publiques)
    • utils/documentPermissions.ts exporte DOCUMENT_TYPES = { … } as const + DocumentType = (typeof DOCUMENT_TYPES)[keyof …] (V2 : 'planche' | 'planche_tracee' | …, singulier/snake_case, valeurs DB)
  • Un bridge v1ToDbType (côté API) + mapping symétrique (frontend, DocumentEditModal) faisaient l'aller-retour.
  • ~15 min perdues à comprendre lequel est canonique lors de l'audit.
  • Coût caché révélé : GET /api/documents retournait V1 alors que POST /upload retournait V2 — incohérence d'API publique silencieuse, deux endpoints renvoyant deux dialectes du même concept.

Cause

Un package shared existe précisément pour empêcher la duplication de contrats. Y greffer deux dialectes du même type viole sa raison d'être. L'installation est progressive et invisible :

  1. Itération 1 : type défini dans dto/, valeurs naturelles pour les URLs publiques.
  2. Itération 2 : un autre dev a besoin du type pour la DB et le redéfinit localement avec les valeurs DB.
  3. Itération 3 : un bridge v1ToDbType est créé pour les concilier.
  4. Le mismatch est vu mais accepté comme « rétrocompat ».
  5. Le bridge se propage à 5+ call-sites ; aucun renommage n'est tenté car « trop de choses cassent ».

C'est une dette historique déguisée en nécessité technique.

Correctif / règle à retenir

  • Détection : grep -rn "export.*DocumentType\|export const DOCUMENT_TYPES" packages/shared/src → plus d'une définition d'un même type avec des valeurs différentes = flag rouge.
  • Remédiation audit-driven (15-30 min de cartographie AVANT de coder) :
    1. lister toutes les valeurs des deux dialectes,
    2. identifier qui utilise V1, qui utilise V2, où vit le bridge,
    3. choisir le dialecte canonique (en général celui qui matche l'UI vivant ; la DB se migre),
    4. migration SQL atomique pour aligner la DB,
    5. source unique (utils/X.ts), ré-export depuis dto/X.ts,
    6. suppression du bridge et de tout type fossile,
    7. relancer toutes les suites avant push.
  • Effort typique : ~2h pour ~25 fichiers + ~10 tests adaptés.
  • Prévention : refuser en review tout PR qui ajoute une 2ᵉ définition d'un type métier dans le package partagé ; exiger une RFC si un nouveau dialecte est vraiment nécessaire.

Flakiness « socket hang up » en e2e Jest/supertest : recyclage de port éphémère

Contexte

app-alexandrie, story infra-8 (code review), le 21-05-2026. Une suite e2e Jest API échouait de façon non déterministe. Complète le diagnostic partiel d'infra-6 (cycle de vie des apps) qui n'avait pas résolu le problème : infra-8 a trouvé la vraie cause racine.

Symptômes

  • Échecs non déterministes : socket hang up, mais AUSSI 400 (body Zod rejeté sur un body pourtant valide), 404/401/501 sur des routes existantes, parfois une suite entière qui tombe.
  • Re-run isolé toujours vert.

Fausses pistes (toutes réfutées par mesure)

  • --runInBand : le retirer AGGRAVE (plus de serveurs HTTP simultanés) → ce n'est pas un problème de concurrence à supprimer.
  • --detectOpenHandles : ne montre RIEN → ce n'est pas une fuite de handle.
  • Apps NestJS non fermées : instrumentation {port, closed}open=0, tous les serveurs sont bien fermés.

Cause racine

Pattern request(app.getHttpServer()) sans app.listen(). supertest fait alors app.listen(0) (port éphémère) à chaque requête. Une suite lourde qui crée une app NestJS par test (20+ apps) recycle 20+ ports éphémères. À la transition « app N libère le port P → app N+1 réobtient P », une connexion TCP initiée pile à cet instant est servie par le mauvais serveur ou réinitialisée → d'où les symptômes variés.

Correctif / règle à retenir

  • Helper startE2EApp(moduleFixture, options?) qui fait app.listen(<port>) une fois, sur un port monotone (compteur incrémental, jamais recyclé dans un run) + keepAliveTimeout=0 + repli listen(0) si EADDRINUSE. Toutes les suites bootent via ce helper. Résultat : 37 runs verts consécutifs.
  • Règle : tout projet e2e Jest/supertest qui boote des apps HTTP en boucle doit utiliser un port stable/monotone, jamais listen(0) implicite par requête.
  • Leçon de méthode : ne pas s'arrêter au 1er symptôme (socket hang up). Les symptômes secondaires (400 sur body valide, 501 introuvable dans le code) sont la clé : ils prouvent que la requête atteint le MAUVAIS serveur → collision de port.

rtk (proxy de tokens) masque ou tronque la sortie des CLI de build/test

Contexte

app-alexandrie, le 26-05-2026. Plusieurs heures perdues en faux diagnostics : rtk (Rust Token Killer, proxy de tokens des commandes) intercepte les CLI jest, prisma, playwright, tsc et tronque ou vide leur sortie.

Symptômes

  • prisma migrate status affiche « 0 applied / 0 pending » alors que les migrations sont bien appliquées → faux finding de code review « migrations non appliquées ».
  • Sorties jest vides ou avec « All parsing tiers failed ».
  • Runs relancés inutilement sur la base de sorties trompeuses.

Cause

Le filtrage rtk n'est pas transparent pour les outils à sortie structurée volumineuse : il peut vider ou tronquer le flux, donnant l'illusion d'un échec ou d'un état vide.

Correctif / règle à retenir

  • Pour tout outil à sortie structurée critique (jest, prisma, playwright, tsc en mode diagnostic) : invoquer via rtk proxy <cmd> pour bypasser le filtrage, OU rediriger vers un fichier et le lire directement.
  • Ne JAMAIS conclure un diagnostic (migration non appliquée, tests échoués, etc.) sur une sortie passée par rtk sans l'avoir confirmé via rtk proxy ou la source réelle (ex. requête pg directe pour l'état des migrations/index).

vi.spyOn sur un module ESM : intercepte-t-il vraiment l'appel ?

Contexte

Test de résilience d'une notification, RL799_V2, story v2-1-4 (review), le 13-06-2026. Doute récurrent et coûteux : « mon vi.spyOn sur un module ESM intercepte-t-il vraiment l'appel fait par le code testé, qui importe la fonction en top-level ? »

Symptôme du doute

Le code testé fait import { batchCreateNotifications } from '...' au top-level. Le binding semble figé, et un spy posé sur l'objet module pourrait ne PAS intercepter cet appel indirect.

Réponse vérifiée empiriquement

vi.spyOn(moduleNamespace, 'fn') intercepte bien un import top-level import { fn } — sous Vitest avec interop CJS (projet sans "type": "module"). esbuild transforme les imports ESM en accès CJS via des getters live (bindings vivants), donc vi.spyOn sur le namespace réimporté (await import(...)) intercepte l'appel indirect.

Méthode de levée de doute

Test-sonde temporaire comptant (a) les appels du spy, (b) les effets réels en DB/mock. Si le spy est appelé N× ET zéro effet réel observé → l'interception marche. Ne pas présumer un faux positif sans cette sonde. Cas vécu : spy mockRejectedValue sur batchCreateNotifications → vérifié : 201 rendu, 0 notif écrite, catch loggé.


as const sur un objet d'options passé à une lib tierce : TS2769 (arrays readonly)

Contexte

Spike Keycloak RL799_V2 (session.ts), le 13-06-2026. Réflexe « je fige mes constantes en as const » appliqué à une whitelist d'algorithmes jose.

Symptômes

  • Typecheck cassé en TS2769 — No overload matches this call sur un appel qui compilait avant.
  • Message peu parlant qui ne pointe PAS vers la readonly-ness.

Cause

as const transforme les string[] en readonly string[]. Si la signature de la fonction tierce attend un array mutable (string[]), TS rejette :

const OPTS = { keyManagementAlgorithms: ['ECDH-ES'] } as const;
jose.jwtDecrypt(jwe, key, OPTS); // TS2769 : keyManagementAlgorithms est readonly

Correctif / règle à retenir

  • Soit typer l'objet explicitement : const OPTS: { keyManagementAlgorithms: string[] } = { … };
  • Soit spread à l'appel : { keyManagementAlgorithms: [...MY_CONST] }.
  • À garder en tête : un as const ajouté « pour bien faire » peut faire échouer soudainement un appel qui compilait.

Échec massif soudain de la suite de tests (DB-per-worker) = template/worker DB corrompu, pas une régression de code

Contexte

RL799_V2, session v2-1-5, le 13-06-2026. Du jour au lendemain, 107 fichiers de tests échouent alors que rien de structurant n'a changé, et le compte total de tests est anormal (beaucoup de skipped).

Symptômes

  • 100+ fichiers KO soudainement, sans changement applicatif correspondant.
  • Compte de tests anormal, nombreux skipped.

Cause

Architecture « template database clonée par worker ». Deux causes typiques :

  • (a) DB worker orphelines (*_test_w1/w2/…) laissées par un run tué → collision au clonage du run suivant.
  • (b) Template Prisma en P3009 (« migrate found failed migrations ») quand un migrate deploy de reconstruction du template a été interrompu en plein milieu — la table _prisma_migrations garde une migration marquée failed, et toute reconstruction ultérieure refuse d'avancer.

Cas vécu : P3009 sur cotisation_payment_amount_bounds (migration sans rapport avec le code touché) après interruption.

Correctif / règle à retenir

  • Fix : drop COMPLET du template + des DB worker orphelines, puis reconstruction from scratch (ensureTemplateReady) — un template neuf n'a pas de migration failed.
  • Réflexe : avant de débugger un « échec massif », vérifier SELECT datname FROM pg_database WHERE datname LIKE '<prefix>_test%'; et tenter un migrate deploy isolé sur le template pour lire l'erreur réelle (souvent masquée par le runner en parallèle).

Séparer deux chantiers mélangés dans un working tree non commité (barrel partagé), sans git add -p

Contexte

RL799_V2, code-review de la story v2-2-1 (module MC), le 18-06-2026. La réconciliation git status vs File List de la story a révélé qu'un SECOND chantier disjoint (ODJ Lot 1) contaminait le working tree non commité, partageant un barrel (packages/shared/src/index.ts) avec le chantier en revue. git add -p interactif est indisponible en agent.

Symptômes / signal de détection

À faire en début de toute code-review :

git status --porcelain | grep -v <dossier-artefacts>

Comparer à la File List déclarée de la story. Tout fichier modifié hors File List = soit doc incomplète, soit contamination par un autre chantier. Discriminer en git diff <fichier> (commentaires/symboles trahissent le chantier d'origine, ex. // Lot 1 allowedGrades). Vérifier la disjonction : grep -rn <symboles-chantier-B> <dossiers-chantier-A> → si vide, les deux sont indépendants et séparables.

Procédure de séparation (agent, add -p indisponible)

  1. git reset pour repartir d'un index vide.
  2. git add -- <fichiers purs du chantier A> (entiers, modifiés + untracked).
  3. Pour un FICHIER PARTAGÉ (barrel touché par A ET B) :
    • git diff <barrel> > /tmp/full.patch,
    • repérer les bornes de hunk (grep -nE '^@@'),
    • reconstruire un patch ne contenant QUE les hunks de A via sed -n (entête lignes 1-4 obligatoire + hunks voulus),
    • valider git apply --cached --check /tmp/a.patch, puis git apply --cached /tmp/a.patch.
  4. Vérifier la coupe : git diff --cached <barrel> (= A seul) et git diff <barrel> (= B seul).
  5. Committer A (thématique) ; B reste non commité pour SON passage en review.

Règle à retenir

  • Garde-fou : retirer un hunk intermédiaire ne casse pas les hunks suivants tant que les zones ne se chevauchent pas (contexte ≥ quelques lignes d'écart) — git apply réapplique chaque hunk via son contexte sur le fichier d'origine.
  • Sanity post-séparation : rebuild du package partagé (tsc -p) avec le reste du tree présent.
  • Cas vécu : commit MC 587d8b5b isolé du chantier ODJ (hunks mcChecklist/getMaterialChecklist vs deriveTemplateItemGrades dans le barrel).

Modèle Prisma fantôme : migration SQL sans model dans schema.prisma

Contexte

RL799_V2, Epic v2-4 Hospitalier, le 20-06-2026. Un commit de feature a livré un module complet (repository, service, routes, DTOs, schémas Zod, tests) AVEC les migrations SQL créant la table (CREATE TABLE care_contacts + enum), MAIS le model CareContact { } n'a jamais été ajouté à prisma/schema.prisma.

Symptômes

  • CI rouge au build API (next build) : Type error: Property 'careContact' does not exist on type 'PrismaClient<...>'.
  • Le typecheck local pouvait passer si le dev n'avait pas régénéré le client après le pull (client en cache encore aligné sur un état antérieur).

Cause racine

La table existe en base, le code l'appelle via prisma.careContact, mais le client généré (dérivé du schéma déclaratif, pas de la base) ignore le modèle. SQL et schéma déclaratif ont divergé.

Correctif / règle à retenir

  • Fix : ajouter le model + l'enum au schéma, calés EXACTEMENT sur le SQL des migrations (nom de table via @@map, colonnes via @map, index, FK, onDelete), puis prisma generate. AUCUNE nouvelle migration — la table existe déjà, on réaligne seulement le schéma déclaratif sur l'état réel. Penser aux relations inverses sur les modèles cibles des FK (ici 2 FK vers users ⇒ 2 relations nommées distinctes sur User, sinon erreur de relation ambiguë).
  • Détection proactive (audit anti-fantôme) : diff entre les CREATE TABLE des migrations et les @@map/noms de modèles du schéma — comm -23 <(grep 'CREATE TABLE' migrations | extract) <(grep '@@map' schema | extract). Tout écart doit s'expliquer par un DROP/rename/fusion ultérieur ; sinon c'est un fantôme. Attention aux faux positifs (commentaires SQL contenant « CREATE TABLE »).
  • Prévention : toute évolution de schéma passe par schema.prisma D'ABORD (prisma migrate dev génère le SQL depuis le schéma). Si on édite une migration à la main (ADD VALUE sur enum, data-fix), vérifier que le schéma déclaratif reflète bien l'état final. Test de garde possible : assertion CI que toute table en base a un modèle dans le schéma.

Effet iceberg CI (variante) : extension d'enum/catalogue sans mise à jour des consommateurs exhaustifs

Contexte

RL799_V2, Epic v2-4 Hospitalier + relance, le 21-06-2026. Suite directe du postmortem « modèle Prisma fantôme » : le déblocage du build CI a révélé 2 strates supplémentaires, toutes de la même nature (extension d'un ensemble « fermé » sans mise à jour des consommateurs exhaustifs). Variante ciblée de l'effet iceberg générique (voir « Effet iceberg en CI — patcher en cascade » plus haut).

Strates (ordre du pipeline : build → test:api → test:frontend → test:shared)

  1. Build API : model CareContact absent de schema.prisma (cf. postmortem fantôme).
  2. test:api : actions d'audit care_contact:created + soiree:reminder_sent loggées dans le code mais absentes du catalogue AUDIT_ACTION_CATALOG (un test de couverture scanne le code et exige la déclaration).
  3. typecheck frontend : valeur d'enum CONVOCATION_REMINDER ajoutée à NotificationType mais absente du Record<NotificationType, IconConfig> du composant (vue-tsc exige l'exhaustivité ; un fallback runtime ?? {...} masque le crash mais PAS l'erreur de type).

Le runner s'arrête à la 1ʳᵉ étape échouée → les strates aval sont invisibles tant que l'amont n'est pas réparé.

Leçons à retenir

  • Leçon 1 — consommateurs exhaustifs d'un enum. Ajouter une valeur à un enum/catalogue « fermé » casse en aval, sans erreur au site d'ajout, trois familles de consommateurs : (a) Record<Enum, X> TS, (b) catalogues testés en couverture, (c) switch sans default. Checklist : à chaque nouvelle valeur d'enum, grep -rn "Record<MonEnum\|MON_CATALOG\|switch.*monType" sur front + shared + back.
  • Leçon 2 — méthode de rejeu CI. Après un fix CI structurant (build, ordre d'étapes, import cassé), rejouer la suite COMPLÈTE dans l'ordre exact du pipeline avant de pusher, jamais une suite isolée — sinon on découvre les strates une par une au rythme du CI (yo-yo ~6-9 min/run). Le coût des suites locales cumulées (~3 min) est très inférieur. Lire .github/workflows pour connaître l'ordre exact des étapes plutôt que de présumer.