Relire du code écrit par une IA : les bugs qu'elle laisse passer, mesurés

Un assistant de code vous rend une fonction de dix lignes, propre, commentée. Vous la relisez en diagonale, elle a l'air juste, vous l'acceptez. Toute la question est de savoir où regarder pendant ces trente secondes de relecture. Plutôt que de recopier une liste de pièges trouvée ailleurs, j'ai demandé des fonctions à trois modèles et je les ai toutes exécutées contre des tests écrits avant de lire la première réponse.

Le résultat n'est pas celui que j'attendais en préparant le banc. Les grands classiques (arrondi en virgule flottante, tri par défaut, échappement HTML, changement d'heure, promesse oubliée) ne sont pas tombés. Ce qui est passé, ce sont des bugs plus discrets, dont un que les trois modèles ont reproduit à chaque essai quand la consigne laissait le mot « caractère » sans définition. Tout ce qui suit a été mesuré le 14 octobre 2026, et les chiffres valent pour cet échantillon, pas pour « l'IA » en général. C'est le pendant de mon test sur ce qu'un débutant doit encore maîtriser à l'ère de l'IA : là, les assistants corrigeaient mes erreurs ; ici, je cherche les leurs.

Huit fonctions, trois modèles, deux façons de demander

J'ai écrit huit consignes de petites fonctions JavaScript, chacune visant un piège connu : arrondir un montant au centime, ajouter des jours à une date, calculer une médiane, lire une quantité tapée dans un formulaire, fabriquer une ligne HTML à partir d'un commentaire de visiteur, filtrer un tableau avec un prédicat asynchrone, tronquer un texte, trier des noms de famille. Pour chacune, une grille de cas limites et une implémentation de référence qui la passe ont été figées avant la première génération, avec leur empreinte SHA-256 et l'heure dans un journal. J'ai aussi vérifié que la grille fait échouer des versions naïves écrites exprès : Math.round(montant * 100) / 100, sort() sans comparateur, texte.slice(), un filtre qui oublie d'attendre ses promesses.

Trois modèles ont répondu, cinq fois par consigne, en un seul tour, depuis un dossier vide. Claude Sonnet 5.5 est le modèle que Claude Code 2.1.299 utilise par défaut en mode claude -p sur mon compte ; Claude Opus 5.5 passe par le même outil avec --model opus ; la série Codex passe par Codex CLI 0.162.0, avec un effort de raisonnement moyen, et je l'appelle gpt-6.1-sol d'après l'en-tête model: gpt-6.1-sol que codex exec affiche avec ma configuration. Je l'ai relevé le 14 octobre 2026, après les générations ; l'en-tête n'est pas archivé avec les 80 réponses. Côté Claude, les outils étaient désactivés ; côté Codex, le bac à sable était en lecture seule, et aucune des 80 réponses de Codex n'a lancé de commande. Un biais à connaître : les deux outils chargent mes instructions globales (un fichier CLAUDE.md pour Claude Code, un AGENTS.md pour Codex : langue, ponctuation, règles de relecture), qui ne parlent d'aucune de ces fonctions.

Chaque réponse devait tenir en un seul bloc de code. Le bloc a été extrait puis exécuté sous Node 20.19.4, sur Windows 11, dans deux fuseaux horaires : Europe/Paris et America/New_York. Une réponse passe si elle réussit tous les cas de la grille dans les deux.

Chaque consigne a été posée deux fois. La version précise dit tout ce que le test vérifie : « un demi-centime exact s'arrondit vers le haut », « un emoji compte pour un caractère », « uniquement des chiffres de 0 à 9 ». La version courte ressemble à ce qu'on tape vraiment, par exemple pour la troncature : « coupe un texte trop long à max caractères », suivi du caractère à ajouter à la fin. Je l'ai écrite après avoir vu les résultats de la version précise, et figée, avec la même grille, avant sa propre génération. J'y ai classé d'avance les cas qui ne découlent pas de la phrase, pour distinguer une consigne violée d'une attente que je n'avais pas formulée. Huit consignes, deux versions, trois modèles, cinq essais : soit 240 réponses.

Consigne précise : un seul échec, et il est vicieux

Avec la consigne précise, 119 réponses sur 120 passent la grille complète : Opus 40 sur 40, gpt-6.1-sol 40 sur 40, Sonnet 39 sur 40. L'unique échec est une réponse de Sonnet sur les dates, et il a exactement la tête d'un code qu'on accepte sans le lire. Voici les trois lignes du calcul, recopiées telles quelles :

  const [annee, mois, jour] = dateISO.split('-').map(Number);
  const date = new Date(Date.UTC(2000, mois - 1, jour + n));
  date.setUTCFullYear(date.getUTCFullYear() + (annee - 2000));

Le calcul se fait en UTC, donc à l'abri des changements d'heure (la réponse passe d'ailleurs tous les cas de la grille qui en traversent un). Mais il se fait dans l'année 2000, avant de remettre la bonne année. Or 2000 est bissextile et 2026 ne l'est pas. Pour « 2026-03-01 moins un jour », le calcul tombe sur le 29 février 2000 ; une fois l'année remise à 2026, ce 29 février n'existe pas et JavaScript le reporte au jour suivant. Le même enchaînement, isolé :

const date = new Date(Date.UTC(2000, 2, 1 - 1));
console.log(date.toISOString().slice(0, 10));
date.setUTCFullYear(2026);
console.log(date.toISOString().slice(0, 10));
2000-02-29
2026-03-01

La fonction renvoie donc la date de départ au lieu de la veille, et « 2026-01-31 plus 30 jours » donne le 1er mars au lieu du 2. Rien dans la réponse n'explique ce détour par l'an 2000. Une raison possible existe, que je ne peux pas attribuer au modèle : la documentation de Date.UTC précise que les années 0 à 99 y sont converties en 1900 à 1999. Mais c'est précisément le genre de détour qu'une relecture doit questionner : quand un code calcule dans un autre cadre que celui du problème, demandez-vous ce que ce cadre a de différent.

Les pièges célèbres qui ne sont pas tombés

Je m'attendais à pire sur les autres consignes. Un résultat négatif se publie aussi, alors le voici, les deux versions de consigne réunies, soit 30 réponses par fonction. Les 30 fonctions d'arrondi passent : aucune ne rend 1 pour 1,005, ce que fait pourtant Math.round(montant * 100) / 100. Les 30 médianes passent, aucune ne rendant 55 pour [10, 9, 1, 100] comme le ferait un sort() sans comparateur, et 0 sur 30 modifient le tableau reçu, alors que la consigne ne l'interdisait pas.

Les 30 lignes HTML passent : une balise <img onerror> glissée dans le nom d'un visiteur ressort affichée comme du texte, jamais interprétée. Construire du HTML par concaténation reste fragile, et l'article sur la manipulation du DOM montre l'option la plus sûre quand on écrit dans la page : textContent. Enfin, les 30 filtres asynchrones passent, et les 30 tris de noms passent aussi, avec « Éluard » rangé parmi les E même quand la consigne courte ne parlait ni d'accents ni de majuscules.

Mon interprétation, que ce banc ne démontre pas : ces pièges sont si documentés que les modèles les contournent par réflexe, du moins sur une fonction isolée qui porte un nom explicite. Dans un vrai projet, le même arrondi se cache au milieu d'un calcul de panier, et rien ici ne mesure ce cas.

Consigne courte : deux fonctions à zéro

Avec la phrase courte, 90 réponses sur 120 passent la grille complète. Six fonctions sur huit restent à 15 sur 15 ; les deux autres tombent à zéro.

Consigne précise courte Arrondi 15 15 Dates 14 15 Médiane 15 15 Quantité 15 0 Ligne HTML 15 15 Filtre async 15 15 Tronquer 15 0 Tri des noms 15 15 Réponses qui passent toute la grille, sur 15 (3 modèles × 5 essais)
Pour chaque fonction, le nombre de réponses qui passent tous les cas, sur 15. Mêmes modèles, même grille : seule la consigne change.

Un point compte pour l'honnêteté du chiffre : sur les cas que la phrase courte définit sans ambiguïté, les 120 réponses passent. Tous les échecs portent sur des cas que j'avais classés d'avance comme « non dits ». Ce ne sont donc pas des consignes violées, ce sont des décisions prises à ma place. Et c'est exactement ce que la relecture doit trouver, parce que ces décisions ne sont pas toujours signalées.

Le caractère coupé en deux, à chaque essai

La consigne courte demandait de couper un texte trop long à max caractères, points de suspension compris. Elle ne disait pas ce qu'est un caractère, et chacune des quinze réponses en a choisi une définition ; certaines la précisent en commentaire, aucune ne garantit de préserver les caractères visibles. 11 comptent en points de code avec Array.from, 3 comptent en unités UTF-16 avec length en évitant de couper une paire de substitution, une compte en unités UTF-16 sans aucune précaution. Voici la forme la plus fréquente, recopiée d'une réponse de gpt-6.1-sol, avec trois appels ajoutés :

export function tronquer(texte, max) {
  if (max <= 0) return "";

  const caracteres = Array.from(texte);
  if (caracteres.length <= max) return texte;

  return caracteres.slice(0, max - 1).join("") + "\u2026";
}

console.log(tronquer("Bonjour tout le monde", 8));
console.log(tronquer("👍🏽👍🏽", 2));
console.log(tronquer("🇫🇷🇩🇪🇮🇹", 2));
Bonjour…
👍…
🇫…

Sur un exemple en ASCII, tout va bien. Des lettres latines suivies d'un accent combinant suffisent déjà à faire échouer cette version (voir plus bas). Et les deux pouces levés à la peau mate, qui tiennent pourtant dans la limite de deux caractères visibles, sont coupés : il reste un pouce jaune et les points de suspension. Le drapeau français se réduit à son premier symbole, une lettre F isolée. Ce comportement est documenté : MDN écrit, à propos du parcours d'une chaîne, que « grapheme clusters will be split, but surrogate pairs will be preserved ». Un emoji avec une couleur de peau, un drapeau ou une famille sont des grappes de plusieurs points de code, que le lecteur voit comme un seul caractère. Les quinze réponses échouent sur les pouces à la peau mate, le drapeau, la famille et une lettre accentuée écrite avec un accent combinant.

Ce n'est pas le pire. 4 réponses sur 15 échouent même sur trois pouces jaunes, un emoji tout simple. Les trois réponses de Sonnet qui comptent en unités UTF-16 renvoient « … » seul pour tronquer("👍👍👍", 2) : le premier pouce est retiré pour ne pas couper sa paire. La réponse sans précaution, une autre réponse de gpt-6.1-sol, renvoie une demi-paire de substitution suivie de « … », une chaîne qui n'est plus du texte valide :

  if (texte.length <= max) return texte;
  return texte.slice(0, max - 1) + "…";

Avec la consigne précise (« un emoji compte pour un caractère »), les 15 réponses passent tous les cas et utilisent Intl.Segmenter, l'outil prévu pour découper un texte en caractères visibles. Ces modèles avaient donc la bonne solution à disposition ; il fallait le mot juste pour qu'ils la sortent.

Dernier détail, le plus instructif pour un relecteur. Une réponse d'Opus annonce, dans son commentaire de documentation :

 * Tronque un texte à `max` caractères au plus, points de code Unicode
 * (les emojis et autres paires de substitution ne sont jamais coupés).

La première ligne décrit fidèlement le code. La seconde est fausse pour le pouce à la peau mate, le drapeau et la famille, qui sont des emojis et qui sont coupés. Un commentaire rassurant n'est pas une preuve : c'est une affirmation de plus à tester.

La quantité entourée d'espaces : une décision que personne n'a prise

Deuxième fonction à zéro avec la consigne courte : lireQuantite, qui doit renvoyer un entier de 1 à 99, ou null si la saisie n'est pas valide. Les 15 réponses appliquent trim() avant de valider : « 12 » entouré d'espaces, ou suivi d'un retour à la ligne, devient 12. Une réponse d'Opus va plus loin et convertit les chiffres pleine chasse (« 12 ») en chiffres ordinaires, par une normalisation Unicode. En revanche, aucune ne laisse passer « 1e1 », « 0x1A », « 12.0 », « +5 », « -1 » ou « 12abc », toutes écritures que Number() ou parseInt() transforment au moins en partie en nombre.

Accepter des espaces autour d'un nombre tapé est souvent un bon choix d'ergonomie. Mais c'est un choix, et seules 3 réponses d'Opus le disent dans leur commentaire (par exemple « espaces autour tolérés ») ; les douze autres le font sans le dire. Si le serveur, lui, refuse « 12 » avec une espace, le formulaire et l'API ne sont plus d'accord sur ce qu'est une quantité valide. C'est le décalage qu'évite le guide pour valider un formulaire en JavaScript, où une seule règle est partagée et où le serveur garde le dernier mot. Avec la consigne précise (« uniquement des chiffres de 0 à 9 »), les 15 réponses refusent les espaces.

Ce que j'ajoute à ma relecture après ce banc

Cinq habitudes, chacune tirée d'un résultat ci-dessus.

1. Relire la consigne avant le code. Cherchez les mots flous de votre propre demande (caractère, valide, alphabétique, arrondi) et retrouvez dans le code la décision qui a été prise à leur place. Dans ce banc, toutes les erreurs de la consigne courte viennent de là.

2. Tester avec autre chose que de l'ASCII. Une lettre écrite avec un accent combinant, un emoji avec une couleur de peau, un drapeau : trois chaînes de test, et la fonction tronquer la plus fréquente du banc tombe. La version qui passe découpe le texte en caractères visibles :

export function tronquer(texte, max) {
  const decoupe = new Intl.Segmenter("fr", { granularity: "grapheme" });
  const visibles = Array.from(decoupe.segment(texte), (s) => s.segment);
  if (visibles.length <= max) return texte;
  return visibles.slice(0, max - 1).join("") + "\u2026";
}

console.log(tronquer("Bonjour tout le monde", 8));
console.log(tronquer("👍🏽👍🏽", 2));
console.log(tronquer("🇫🇷🇩🇪🇮🇹", 2));
Bonjour…
👍🏽👍🏽
🇫🇷…

3. Questionner les détours. Un calcul fait dans un autre cadre que le problème (l'an 2000, un autre fuseau, une autre unité) mérite un arrêt : c'est dans un tel détour que s'est logé le seul bug de dates du banc. Le détour n'est pas faux en soi ; il doit seulement être justifié, et testé aux bords de ce qu'il change.

4. Traiter les commentaires comme du code. Un commentaire affirme quelque chose ; vérifiez-le comme le reste. Celui de la réponse d'Opus promettait que les emojis ne seraient jamais coupés.

5. Préciser la demande, puis vérifier quand même. Les mêmes modèles, sur les mêmes fonctions, sont passés de 90 à 119 réponses justes sur 120 en changeant seulement la consigne. Une consigne précise coûte quelques phrases de plus ; elle ne dispense pas des tests, puisque l'erreur de l'an 2000 est apparue justement avec la consigne précise. C'est le même partage des rôles que dans mon retour d'expérience sur une application construite par un agent IA : l'agent écrit, la vérification reste de votre côté.

Les limites de ce banc

Huit fonctions courtes, isolées et bien nommées ne sont pas un projet réel : rien ne dit que ces proportions tiendraient dans un fichier de cinq cents lignes. Trois modèles, cinq essais chacun, un seul jour : les proportions valent pour ces versions, pas au-delà. Le modèle Claude par défaut est celui de mon compte, l'effort moyen de Codex a été choisi pour la durée du banc, et les deux outils chargeaient mes instructions globales (CLAUDE.md pour Claude Code, AGENTS.md pour Codex). Aucun outil n'a servi : coupés côté Claude, jamais appelés côté Codex. Un agent qui exécute ses propres tests aurait peut-être attrapé l'erreur de l'an 2000, et ce banc ne le mesure pas. Les grilles et les consignes sont de moi, et une autre phrase courte aurait pu donner d'autres décisions. Le journal d'empreintes couvre la grille, l'évaluateur, les consignes et les références ; le script d'agrégation des scores n'y figure pas. Enfin, le découpage en caractères visibles s'appuie sur les données Unicode embarquées dans le moteur : les sorties citées ici sont celles de Node 20.19.4.

Sources consultées le 14 octobre 2026 :