Trois bugs réels où le clic ne fait rien — Claude se trompe à chaque fois jusqu'à ce qu'une phrase en plus dans le prompt verrouille la réponse.
Les bugs se déclinent en deux catégories. Ceux qui lèvent une erreur — tu donnes la stack trace à Claude et tu as la réponse en 30 secondes. Ceux qui n'en lèvent pas — le bouton qui ne fait rien, la page qui ne bouge pas, le formulaire qui échoue en silence — ceux-là, Claude se plante du premier coup. Pas parce qu'il est bête. Parce qu'il ne voit pas.
J'en ai pris trois d'affilée en construisant le flux de paiement de how2claude. Voici le post-mortem, et les schémas de prompt que j'utilise désormais pour les bugs silencieux.
J'ai branché les paiements crypto x402. En local, ça passait. Premier clic en production : invalid_string at payTo dans la console. Le signing flow n'a même pas démarré — le schéma Zod du facilitator a rejeté la requête avant.
Le wallet était une adresse 0x... de 42 caractères, impeccable à l'œil. J'ai demandé à Claude de vérifier le champ wallet dans config/credentials/production.yml.enc :
w = Rails.application.credentials.dig(:x402, :wallet_address).to_s
puts "length: #{w.length}"
# => 43
43 caractères. Les adresses EVM en font 42. Le 43ᵉ caractère était un point d'interrogation chinois pleine chasse ? (U+FF1F), glissé lors d'un copier-coller depuis une méthode d'entrée chinoise.
Le premier scan de Claude n'a rien signalé — pour lui, c'était une chaîne qui commençait par 0x et paraissait correcte. Il n'a pas compté la longueur de sa propre initiative. Ajoute ceci au prompt : « cette adresse est un caractère plus longue que prévu — imprime chaque codepoint ». 0xFF1F apparaît, affaire classée.
Bouton Subscribe sur la page de pricing — clic, la page ne bouge pas. Aucune erreur. L'onglet network montrait le POST partir, Stripe renvoyer un 302 vers checkout.stripe.com — puis... rien.
J'ai d'abord fait regarder le controller à Claude. Logique OK : redirect_to session.url, allow_other_host: true. Le JS — aucun listener pertinent.
J'ai fini par remarquer le header de réponse : Content-Type: text/vnd.turbo-stream.html. Turbo interceptait la soumission du button_to comme une requête Turbo Stream, et Turbo Stream ne suit pas les 302 cross-origin — la redirection était avalée et la page restait silencieusement en place.
Correctif :
<%= button_to "Subscribe", ..., data: { turbo: false } %>
Le même bug m'a repris un mois plus tard sur le bouton Google OAuth. Les intercepteurs au niveau du framework sont un terrain fertile pour les bugs silencieux — Claude raisonne par défaut de façon linéaire requête/réponse et n'ira pas chercher une couche intermédiaire qui a réécrit la sémantique. Ajoute au prompt : « passe en revue chaque intercepteur au niveau du framework par lequel ce clic transite — liste chaque middleware / couche JS qui traite cette requête sur le trajet navigateur → serveur → navigateur. »
Le controller Stimulus pour le toggle mensuel/annuel de la page de pricing — clic sur le bouton, rien ne bascule. La méthode du controller se déclenchait (confirmé avec console.log), mais this.monthlyTarget valait undefined.
Premier pari de Claude : faute de frappe dans le nom du target. Non. data-pricing-target="monthly" était bien dans le DOM.
Le problème, c'était la portée. data-controller="pricing" était sur le conteneur du bouton toggle, mais les deux sections de grille étaient en dehors de ce conteneur. Stimulus ne cherche les targets que dans le sous-arbre de l'élément controller ; ceux à l'extérieur n'existent pas pour lui. J'ai remonté data-controller sur le <section> qui enveloppe le tout — réglé.
Ce bug hurle « le code est bon » — tous les noms correspondent, tous les attributs sont là, seule la fonctionnalité est cassée. Claude lit le code ligne par ligne par défaut ; il ne va pas visualiser la structure du DOM de lui-même. Ajoute au prompt : « dessine l'arbre des ancêtres et des descendants de l'élément portant data-controller='pricing' — indique quels data-pricing-target tombent dans le sous-arbre et lesquels non. »
Les trois bugs étaient identiques de l'extérieur : clic, rien ne se passe, aucune erreur. Claude s'est trompé à chaque première tentative, et à chaque fois une phrase supplémentaire dans le prompt a verrouillé la réponse. Le schéma commun :
1. Dis-lui le delta quantifié entre attendu et réel — pas seulement « c'est faux »
Pas « l'adresse wallet a un problème », mais « elle est un caractère plus longue que prévu ».
Pas « le bouton ne marche pas », mais « la réponse est un 302, mais le navigateur ne l'a pas suivi ».
Pas « le toggle est cassé », mais « la méthode du controller se déclenche, mais le target est undefined ».
Plus le delta est étroit, plus l'espace de recherche de Claude rétrécit.
2. Pointe-le vers les couches invisibles — framework, navigateur, encodage
Les bugs silencieux ne vivent presque jamais dans ton code métier. Ils vivent dans Turbo, dans la portée de Stimulus, dans l'encodage des caractères, dans CSP, dans CORS, dans les service workers. Par défaut, Claude lit ton code. Dis-lui explicitement d'aller regarder ces autres couches.
3. Demande-lui l'état intermédiaire, pas les conclusions
« Imprime chaque codepoint. » « Liste les headers de réponse. » « Dump le sous-arbre du DOM. » Matérialise l'état intermédiaire plutôt que de demander à Claude de raisonner jusqu'à la réponse. La partie « silencieuse » d'un bug silencieux, c'est qu'une étape de la chaîne de raisonnement repose sur une hypothèse cachée qui ne tient pas. Matérialiser l'état intermédiaire, c'est forcer cette hypothèse à sortir au grand jour.
Les erreurs testent ce que Claude sait. Les bugs silencieux testent la qualité du signal que tu lui donnes. Plus tu es précis, plus vite il trouve la réponse.