Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Ce que l'agent de code a raté, et comment le voir en relisant sa PR

Ou : Comment relire le travail d’un collègue brillant, infatigable, et qui n’a jamais vu ta production

Une PR, la demande de fusion qui propose d’intégrer des changements au code, écrite par un agent de code a une propriété déroutante : elle est propre. Nommage cohérent, tests fournis, description soignée, aucune faute de frappe. C’est justement le problème. On relit une PR humaine en guettant les signes de fatigue, la variable mal nommée à 18 h 40, le test oublié. Celle d’un agent n’en a aucun, et ses erreurs ressemblent à du code correct. Le sondage Stack Overflow de 2025 a mis un chiffre dessus : la première frustration des développeurs, citée par 66 % d’entre eux, ce sont les solutions d’IA « presque justes, mais pas tout à fait »1. En revue de code, presque juste est le pire cas possible. Assez juste pour passer, assez faux pour coûter.

En février, j’expliquais pourquoi tester devient le vrai travail quand l’IA écrit le code : la machine teste ce qu’elle a compris du problème, pas ce que tu voulais. Cet article se place de l’autre côté de la table. La PR est ouverte, les voyants sont verts, et c’est toi qui dois cliquer sur « Fusionner ». Que regardes-tu ?

Revue de code et agents IA : ce qu’une PR propre peut cacher

Le test qui ne prouve rien

Voici une fonction tirée du système de la série sur le magic link, et le test qu’un agent lui associe typiquement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Valid reports whether the token behind hash is still usable.
func (v *Verifier) Valid(ctx context.Context, hash []byte) (bool, error) {
	exp, err := v.store.ExpiresAt(ctx, hash)
	if err != nil {
		return false, err
	}
	return v.now().Before(exp), nil
}

// The kind of test an agent writes: one happy path, the error thrown away.
func TestValid(t *testing.T) {
	v := &Verifier{store: mockStore{exp: time.Now().Add(time.Hour)}, now: time.Now}
	ok, _ := v.Valid(context.Background(), []byte("hash"))
	if !ok {
		t.Fatal("expected valid token")
	}
}

Le test passe, la couverture affiche 75 %, et rien n’a l’air faux. J’ai alors fait ce que je recommande de faire à chaque revue : casser le code à la main. J’ai remplacé tout le corps de Valid par return true, nil. La fonction ne regarde plus rien, ni la base, ni l’heure, ni l’erreur. Le test de l’agent est resté vert.

Il ne pouvait pas en être autrement. Il ne vérifie qu’un cas, celui où la réponse attendue est true, et il jette l’erreur avec ok, _ :=. Une fonction qui répond toujours true satisfait ce test à la perfection. Le test suivant, lui, décrit le comportement plutôt qu’un exemple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func TestValidCases(t *testing.T) {
	now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)
	boom := errors.New("db down")
	cases := []struct {
		name    string
		store   stubStore
		want    bool
		wantErr error
	}{
		{"still valid", stubStore{exp: now.Add(time.Minute)}, true, nil},
		{"expired", stubStore{exp: now.Add(-time.Minute)}, false, nil},
		{"expires right now", stubStore{exp: now}, false, nil},
		{"store fails", stubStore{err: boom}, false, boom},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			v := &Verifier{store: c.store, now: func() time.Time { return now }}
			got, err := v.Valid(context.Background(), []byte("hash"))
			if !errors.Is(err, c.wantErr) {
				t.Fatalf("err = %v, want %v", err, c.wantErr)
			}
			if got != c.want {
				t.Fatalf("got %v, want %v", got, c.want)
			}
		})
	}
}

Sur la fonction vidée, trois cas sur quatre échouent : le token expiré, le token qui expire à la seconde même, la base qui tombe. Sur la vraie fonction, tout passe, avec 100 % de couverture. La question de revue n’est donc pas « y a-t-il des tests ? », mais « ce test échouerait-il si le code était faux ? ». Trente secondes de sabotage manuel y répondent mieux que le pourcentage de couverture, et le procédé porte un nom, le mutation testing, que je traiterai à part.

Mes propres erreurs comme liste de contrôle

La meilleure source pour une liste de contrôle, ce sont les erreurs qu’on a soi-même commises ou failli commettre. Ce blog en contient plusieurs, documentées.

Dans la vérification du magic link, la version naïve lisait le token par un SELECT, puis le supprimait par un DELETE. Vingt clics simultanés ouvraient vingt sessions dans presque toutes les rondes du test, et go test -race restait vert, parce que le détecteur surveille la mémoire du programme, pas les lignes de la base. C’est exactement le code qu’un agent écrit quand il traduit un algorithme étape par étape. Dans le même article, une comparaison en temps constant comparait deux valeurs que l’attaquant fournissait toutes les deux : parfaitement sûre, et parfaitement inutile. Je ne l’ai vue qu’en écrivant le test.

Dans l’article sur l’injection SQL en Go, des méthodes de GORM acceptent du SQL brut sans l’échapper, et l’analyseur gosec 2.29.0 n’a signalé aucun des cinq appels GORM vulnérables testés. Et dans l’anatomie des attaques sur OpenClaw, plus d’un millier d’extensions malveillantes se faisaient passer pour des outils légitimes.

Ce dernier point a un équivalent direct dans les PR d’agents : les paquets inventés. Une étude présentée à USENIX Security en 2025 a analysé 576 000 extraits de code produits par 16 modèles : en moyenne, au moins 5,2 % des paquets recommandés par les modèles commerciaux n’existaient pas, et 21,7 % pour les modèles ouverts, soit 205 474 noms inventés différents2. Un nom inventé est un nom libre, qu’un attaquant peut enregistrer avec du code malveillant dedans. L’attaque a même reçu un nom, le slopsquatting3.

La liste

#Ce que l’agent fait de traversComment le voir en revue
1Un test qui ne vérifie que le chemin heureux, ou qui teste son propre faux objetCasser le code à la main : le test doit passer au rouge
2Une erreur avalée : _ =, ok, _ :=, un bloc catch videChercher ces motifs dans le diff, un par un
3Une dépendance ajoutéeVérifier que le paquet existe, son âge, son mainteneur, et s’il était nécessaire
4Une fonction ou une option qui n’existe pas dans la version utiliséeOuvrir la documentation de la version du go.mod, pas celle de la mémoire du modèle
5Une lecture puis une écriture en deux requêtesRepérer un SELECT suivi d’un UPDATE ou d’un DELETE sur la même ligne
6Du SQL assemblé avec fmt.Sprintf ou une concaténationChercher Sprintf à proximité de Raw, Where, Order ou Exec
7Un contrôle de sécurité dont les deux entrées viennent de la requêteDemander, pour chaque comparaison : qui contrôle chaque côté ?
8Un secret en dur, ou une donnée sensible écrite dans les journauxChercher token, secret, password dans les appels de journalisation
9Du code mort : fonctions jamais appelées, fichiers en tropgo vet et staticcheck, puis la question « qui appelle ça ? »
10Un commentaire qui décrit ce que le code devrait faireLire le code avant le commentaire, jamais l’inverse
11Des modifications hors du périmètre demandéCommencer par git diff --stat : chaque fichier touché doit avoir une raison
12Un test existant modifié pour passerRelire le diff des _test.go : assertion affaiblie, cas supprimé, t.Skip ajouté

Le dernier point mérite qu’on s’y arrête. Un agent à qui l’on demande de faire passer les tests dispose de deux méthodes : corriger le code, ou corriger le test. La seconde est souvent plus courte. Un test modifié dans une PR qui ne devait pas changer de comportement est le signal le plus fiable de toute la liste.

Le moment où Dave fusionne

DevOps Dave : L’agent a ouvert la PR. 94 % de couverture, tout est vert, la description est meilleure que les miennes. Je fusionne ?

Security Sarah : Remplace le corps de la fonction principale par un return en dur, et relance les tests.

DevOps Dave : … Toujours vert.

Security Sarah : Alors tu n’as pas 94 % de couverture. Tu as 94 % de lignes visitées.

Dave n’a rien fait de travers selon les critères habituels. Il a regardé les indicateurs que la revue humaine a toujours regardés, et qui supposaient qu’un test écrit par quelqu’un avait été pensé par quelqu’un. Avec un agent, cette supposition ne tient plus. Le test existe, il a été écrit, mais personne ne s’est demandé ce qu’il prouvait.

L’agent écrit plus vite que moi, et c’est une bonne nouvelle. Mais il déplace le goulot d’étranglement de l’écriture vers la relecture, et ce goulot, c’est moi. Relire la PR d’un agent, c’est relire le travail d’un collègue brillant, infatigable, et qui n’a jamais vu ta production. Il ne ment pas. Il ne sait simplement pas ce qu’il ne sait pas, et c’est exactement ce que la revue doit savoir à sa place.

Le code de cet article a été compilé et testé sous Go 1.27.1, go vet compris, sur la fonction réelle et sur sa version vidée.



  1. Le sondage Stack Overflow 2025, réalisé de mai à juin 2025, donne aussi le contexte : 84 % des répondants utilisent ou prévoient d’utiliser des outils d’IA, 46 % se méfient de l’exactitude de leurs réponses contre 33 % qui leur font confiance, et 3 % seulement leur font « très » confiance. 45,2 % trouvent que déboguer du code généré par l’IA prend plus de temps. Le communiqué de Stack Overflow du 29 juillet 2025 reprend les chiffres de l’usage et de la méfiance. Les résultats de 2026 n’étaient pas encore publiés au moment d’écrire ces lignes. ↩︎

  2. L’article « We Have a Package for You! », de Joseph Spracklen et ses coauteurs, a été présenté au 34e USENIX Security Symposium. Les extraits de code étaient en Python et en JavaScript. Les modèles commerciaux s’en tirent mieux que les modèles ouverts, mais 5,2 % de paquets fantômes, multipliés par le nombre de PR fusionnées chaque jour, restent un risque que personne n’a envie de découvrir en production. ↩︎

  3. Le terme a été forgé par Seth Larson, chargé de la sécurité à la Python Software Foundation, comme le raconte The Register en avril 2025. « Slop » désigne, de façon péjorative, la production en masse des modèles. Le typosquatting comptait sur nos fautes de frappe ; le slopsquatting compte sur l’imagination des modèles. ↩︎