Aspire : garde-fous du code d’agent — l’analyseur Roslyn vit DANS la compilation

Nos notebooks précédents (01-05) ont construit une pile d’agent .NET complète : orchestration Aspire, stack GenAI réelle, observabilité, agent streaming, tests d’intégration. Ce notebook attaque la question qui vient après : quand un agent (LLM) génère du code C# pour cette pile, qu’est-ce qui l’empêche d’écrire du code dangereux ?

La thèse tient en une ligne de parité :

Python C#/.NET
mypy, ruff — garde-fous HORS compilation (outil séparé à adopter) analyseurs Roslyn — garde-fous DANS la compilation (dotnet build lui-même rend le diagnostic)

En Python, un garde-fou vit dans un lint distinct : il faut installer ruff, le configurer, l’ajouter au pipeline. En .NET, l’analyseur se référence comme n’importe quelle dépendance — et le même dotnet build qui produit le binaire rend le verdict, pour tout le monde, sur la machine de chaque développeur et du premier coup.

Plan : nous construisons de vrais analyseurs (DiagnosticAnalyzer Roslyn) qui attrapent plusieurs défauts typiques du code d’agent généré — blocage synchrone, tâches non observées et annulation perdue — puis nous les voyons tirer sur deux canaux : le canal build (dotnet build rend les avertissements, c’est la thèse) et le canal API (un Verifier qui compile des sources via Roslyn et rend son verdict, comme le fait l’IDE). Nous terminons par le contraste Python, des exemples guidés exécutés et quatre exercices non résolus.

Contexte : le deadlock .Result/.Wait(), blessure classique du code d’agent

Un LLM qui génère du C# « dans le style synchrone » écrit très naturellement :

var reponse = AppelerLeLlmAsync(prompt).Result;   // bloque la Task

Pourquoi c’est mortel et pas juste inélégant : .Result bloque le thread courant jusqu’à ce que la tâche finisse. Si cette tâche a besoin, pour finir, d’un thread du même pool — continuation en attente, contexte de synchronisation, handler ASP.NET qui doit libérer son thread — le programme attend un thread qui ne viendra jamais : deadlock. Symptôme en production : le service se fige silencieusement, aucun crash, aucun log — juste zéro réponse.

C’est précisément le genre de défaut qu’un garde-fou automatique doit attraper : localement visible (une expression), globalement désastreux (un service mort). Et c’est un pattern que les modèles de langage produisent de façon récurrente, parce que leurs données d’entraînement débordent de code synchrone. D’où la règle AGENTGUARD001 : toute Task bloquée synchrone en code d’agent est signalée au build.

using System;
using System.Diagnostics;
using System.IO;

var here = Directory.GetCurrentDirectory();   // le dossier de la serie Aspire

public static class Shell
{
    // Execute un executable et capture stdout+stderr. Pas de shell
    // intermediaire : cmd.exe reecrit toute ligne portant plus d'une paire
    // de guillemets (lecon du notebook 01) -- on lance donc la commande
    // directement, resolue via le PATH.
    public static string Run(string workDir, string cmd, string args)
    {
        var psi = new ProcessStartInfo
        {
            FileName = cmd,
            Arguments = args,
            WorkingDirectory = workDir,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };
        using var p = Process.Start(psi)!;
        var stdout = p.StandardOutput.ReadToEnd();
        var stderr = p.StandardError.ReadToEnd();
        if (!p.WaitForExit(180_000)) p.Kill();
        return stdout + stderr;
    }
}
Console.WriteLine($"Repertoire de travail : {Path.GetFileName(here)}");
Repertoire de travail : Aspire

A1 — Le terrain fautif : un worker d’agent qui bloque sa tâche

Le fichier ci-dessous (AgentGuard.Demo/Program.cs, committé avec ce notebook) est une copie de démonstration du motif canonique de la série (le worker Channel du notebook 04) — avec le défaut injecté : là où la série attend la tâche avec await, ce code la bloque avec .Result, deux fois, « pour simplifier ». C’est le genre de code qu’un agent génère quand on lui demande une version synchrone d’un pipeline asynchrone.

Le fichier ne fait pas planter le notebook : l’exécution du programme elle-même termine (blocage borné sur ce scénario minimal) — le défaut est un risque structurel, pas une exception immédiate. C’est exactement pourquoi il faut un analyseur : aucun test d’exécution ne le révélera tant que le deadlock ne s’est pas produit.

// Le terrain affiche le Demo courant : son output evolue avec Program.cs.
var terrain = File.ReadAllText(Path.Combine(here, "AgentGuard.Demo", "Program.cs"));
Console.WriteLine(terrain);
// Terrain fautif : copie de demo du motif StreamingAgent.App (notebook 04).
// Un worker d'agent consomme une Channel -- motif canonique de la serie --
// mais ici le code genere "dans le style synchrone" BLOQUE la tache d'appel
// au LLM avec .Result au lieu de l'attendre. C'est le defaut que l'analyseur
// AGENTGUARD001 doit attraper au `dotnet build` (ce fichier ne leve PAS
// d'exception : le blocage ne deadlock ici qu'un contexte reduit -- le
// notebook explique pourquoi le pattern reste mortel en production).

using System;
using System.Threading.Channels;
using System.Threading.Tasks;

var channel = Channel.CreateUnbounded<string>();
_ = Producer.ProduceAsync(channel.Writer, "traduis cette phrase en anglais");

Console.WriteLine(AgentWorker.TranslateSync(channel.Reader));

public static class AgentWorker
{
    // Genere par agent "pour simplifier" : la tache async est bloquee.
    // Deux blocages .Result -- deux diagnostics attendus au build.
    public static string TranslateSync(ChannelReader<string> inbound)
    {
        var prompt = inbound.ReadAsync().AsTask().Result;   // AGENTGUARD001
        return CallLlmAsync(prompt).Result;                 // AGENTGUARD001
    }

    private static async Task<string> CallLlmAsync(string prompt)
    {
        await Task.Delay(50);            // simule la latence de l'appel LLM
        return $"[LLM] {prompt}";
    }
}

public static class Producer
{
    public static async Task ProduceAsync(ChannelWriter<string> writer, string prompt)
        => await writer.WriteAsync(prompt);
}

// Second terrain fautif (AGENTGUARD002) : le meme agent, une autre vitesse.
// "Lancer et oublier" un traitement en ecrivant async void -- la signature
// ressemble a une async Task, mais la methode n'est pas attendable et ses
// exceptions ne sont observees par personne. Un diagnostic AGENTGUARD002
// attendu au build (et aucun ici n'est un handler d'evenement : pas de
// signature (object, EventArgs)).
public static class AgentFireAndForget
{
    public static void Demarrer()
    {
        SurveillerCanalAsync();            // "rendu la main" -- silencieusement
    }

    // Genere par agent "pour simplifier" : async void hors handler.
    public static async void SurveillerCanalAsync()
    {
        await Task.Delay(200);             // simule une boucle de surveillance
        // Si l'appel LLM leve ici, personne ne l'observe : process mort.
    }
}

// Troisieme terrain fautif (AGENTGUARD003) : le meme agent, troisieme
// vitesse. `Task.Run(...)` et `Task.Factory.StartNew(...)` sont appeles
// comme enonces autonomes -- leur signature est honnete (Task, pas void),
// mais leur retour est jete a la corbeille : pas de await, pas
// d'affectation, pas de `_ =`, pas de return. Les taches s'executent en
// arriere-plan et leurs exceptions ne sont observees par personne. Deux
// diagnostics AGENTGUARD003 sont attendus au build. Seule cette forme nue
// est signalee : await, affectation, discard et return restent exempts.
public static class AgentTaskRunFire
{
    public static void Demarrer()
    {
        Task.Run(() => Console.WriteLine("run"));                 // AGENTGUARD003
        Task.Factory.StartNew(() => Console.WriteLine("start"));  // AGENTGUARD003
    }
}

// Quatrieme terrain fautif (AGENTGUARD004) : la requete fournit un
// CancellationToken, et la cible sait le recevoir, mais le code genere omet
// l'argument optionnel. L'annulation est perdue au milieu de la chaine.
public static class AgentCancellation
{
    public static async Task RepondreAsync(
        string prompt,
        System.Threading.CancellationToken cancellationToken)
    {
        await CallLlmAsync(prompt);                    // AGENTGUARD004
    }

    private static Task<string> CallLlmAsync(
        string prompt,
        System.Threading.CancellationToken cancellationToken = default)
        => Task.FromResult($"[LLM] {prompt}");
}

// Cinquieme terrain fautif (AGENTGUARD005) : variante syntaxique d'
// AGENTGUARD001, meme defaut semantique. Au lieu de `.Result`, l'agent
// decompile explicitement la machine a etats de la tache :
// `tache.GetAwaiter().GetResult()`. Meme consequence en production
// (deadlock sur un SynchronizationContext), mais un chemin syntaxique
// distinct qui justifie un analyseur dedie -- AGENTGUARD001 ne regarde
// que `.Result` / `.Wait()`. Diagnostic attendu sur la derniere ligne.
public static class AgentSyncOverAsync
{
    // Genere par agent "pour eviter un await" : la tache est "synchronisee"
    // en traversant manuellement la machine a etats. C'est exactement le
    // defaut que AGENTGUARD005 attrape.
    public static string ReponseSynchrone(string prompt)
    {
        return CallLlmAsync(prompt).GetAwaiter().GetResult();   // AGENTGUARD005
    }

    private static async Task<string> CallLlmAsync(string prompt)
    {
        await Task.Delay(50);
        return $"[LLM] {prompt}";
    }
}

// Sixieme terrain fautif (AGENTGUARD005b) : variante
// `ConfigureAwait(false).GetAwaiter().GetResult()`, ecappee au filtre
// semantique d'AGENTGUARD005 (le receiver du GetAwaiter y est de type
// `ConfiguredTaskAwaitable`, pas `Task`). L'agent genere cette forme en
// CROYANT que `ConfigureAwait(false)` "rend ca safe". Faux : ConfigureAwait
// reduit la capture du SynchronizationContext, mais le
// `.GetAwaiter().GetResult()` BLOQUE TOUJOURS le thread -- le
// sync-over-async reste entier. C'est precisement la confusion que la
// regle AGENTGUARD005b doit denoncer. Deux diagnostics attendus : un par
// ligne fautive (deux formes explicitement testees : `ConfigureAwait(false)`
// et `ConfigureAwait(true)`).
public static class AgentSyncOverAsyncConfigureAwait
{
    // Forme "rassurante" classique : l'agent a lu sur Stack Overflow que
    // ConfigureAwait(false) est une bonne pratique et l'a ajoute "pour
    // etre safe". Le defaut est exactement le meme que la variante
    // nue -- diagnostic AGENTGUARD005b attendu.
    public static string ReponseSynchroneSafeFalse(string prompt)
    {
        return CallLlmAsync(prompt).ConfigureAwait(false).GetAwaiter().GetResult();   // AGENTGUARD005b
    }

    // Forme `true` (explicite) -- meme defaut, l'analyseur doit le voir
    // aussi (le literal n'est pas blanchi : seul compte le pattern).
    public static string ReponseSynchroneSafeTrue(string prompt)
    {
        return CallLlmAsync(prompt).ConfigureAwait(true).GetAwaiter().GetResult();    // AGENTGUARD005b
    }

    private static async Task<string> CallLlmAsync(string prompt)
    {
        await Task.Delay(50);
        return $"[LLM] {prompt}";
    }
}

Lecture du terrain

Le worker TranslateSync porte les deux blocages, marqués // AGENTGUARD001 :

  • inbound.ReadAsync().AsTask().Result — on bloque même la lecture du canal d’entrée ;
  • CallLlmAsync(prompt).Result — on bloque l’appel au LLM, la partie la plus lente du pipeline.

Deux expressions, deux endroits où un thread s’endort en tenant le pipeline. La méthode CallLlmAsync est correcte (elle est async) ; c’est le consommateur qui dénature le contrat. Un humain relit rarement ça assez attentivement — l’analyseur, lui, ne rate jamais.

A2 — L’analyseur : une règle qui vit dans la compilation

Voici l’intégralité de l’analyseur (AgentGuard.Analyzers/TaskResultBlockAnalyzer.cs, committé) — un DiagnosticAnalyzer Roslyn réel, pas une simplification :

Il s’abonne aux expressions d’accès de membre (.QuelqueChose), puis filtre en deux étages : un étage syntaxique bon marché (le membre s’appelle-t-il Result ou Wait ?) et un étage sémantique (ce membre appartient-il vraiment au type Task/Task<T> ?). C’est ce second étage qui fait la différence entre un grep et un analyseur : il interroge le modèle sémantique — la compréhension des types que le compilateur construit.

var analyzerSrc = File.ReadAllText(Path.Combine(here, "AgentGuard.Analyzers", "TaskResultBlockAnalyzer.cs"));
Console.WriteLine(analyzerSrc);
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace AgentGuard.Analyzers;

/// <summary>
/// AGENTGUARD001 : blocage synchrone d'une Task (.Result / .Wait()).
///
/// Pattern typique du code d'agent genere : appeler une tache asynchrone
/// (appel LLM, streaming, canal) depuis du code synchrone en la bloquant.
/// Le garde-fou vit DANS la compilation -- `dotnet build` rend le diagnostic
/// sans aucun outil supplementaire (la these du notebook 06 de la serie Aspire,
/// Epic #10473 axe Roslyn).
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class TaskResultBlockAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "AGENTGUARD001";

    private static readonly DiagnosticDescriptor Rule = new(
        DiagnosticId,
        "Blocage synchrone d'une Task",
        "Task bloquee de maniere synchrone ({0}) : deadlock potentiel en code d'agent",
        "Agentisme",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Le code genere par agent qui bloque une Task via .Result/.Wait() expose au deadlock.");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        // Le membre accede (.Result, .Wait) est un MemberAccessExpression --
        // on s'abonne a CE noeud syntaxique, pas a toute l'arborescence.
        context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.SimpleMemberAccessExpression);
    }

    private static void AnalyzeNode(SyntaxNodeAnalysisContext ctx)
    {
        var node = (Microsoft.CodeAnalysis.CSharp.Syntax.MemberAccessExpressionSyntax)ctx.Node;

        // 1. Filtre syntaxique bon marche : le membre s'appelle Result ou Wait.
        if (node.Name is not { Identifier.ValueText: "Result" or "Wait" }) return;

        // 2. Filtre semantique : le membre appartient bien a Task / Task<T>.
        //    (Evince les faux positifs : un Result<T> monadique, un .Wait()
        //    de type custom.) C'est le modele semantique qui tranchera.
        if (ctx.SemanticModel.GetSymbolInfo(node).Symbol is not { } member) return;
        if (member.ContainingType is not INamedTypeSymbol ct
            || ct.MetadataName is not ("Task" or "Task`1")) return;

        ctx.ReportDiagnostic(Diagnostic.Create(
            Rule, node.GetLocation(), "." + node.Name.Identifier.ValueText));
    }
}

Anatomie pas à pas

Trois morceaux portent tout :

  1. DiagnosticDescriptor Rule — la carte d’identité du diagnostic : identifiant (AGENTGUARD001), titre, message (avec {0} rempli au rapport), catégorie, sévérité. isEnabledByDefault: true : le diagnostic est actif dès la référence, sans configuration.
  2. Initialize + RegisterSyntaxNodeAction — l’analyseur ne parcourt PAS tout le code : il s’abonne à un seul type de nœud syntaxique (SimpleMemberAccessExpression), et Roslyn ne l’appelle que sur ces nœuds. C’est ce qui rend l’analyse quasi gratuite.
  3. AnalyzeNode — les deux étages de filtrage : syntaxique d’abord (Result ou Wait — un test de chaîne), sémantique ensuite (GetSymbolInfo → le membre appartient-il à Task/Task<T> ?). Le MetadataName distingue le vrai Task<T> (`Task`1` en métadonnées) de tout homonyme. Seul un nœud ayant passé les deux étages est rapporté — d’où zéro faux positif sur un type custom qui aurait une propriété Result.

La sévérité Warning (et non Error) est un choix : le build ne casse pas, mais le diagnostic est visible à chaque compilation. Passer en Error (via .editorconfig) ferait du garde-fou un bloqueur — une décision d’équipe, pas de l’analyseur.

Exercice 1 – Predire le verdict du Verifier sur le terrain a exemptions

L’analyseur AGENTGUARD005b est livre, ses exemptions sont posees explicitement. Le terrain SyncOverAsyncConfigureAwaitValueTask.cs materialise trois cas d’exemption – tous les trois legitimes. TODO etudiant : avant d’executer la cellule Verifier, predire le verdict de chacun (rouge / propre), puis executer, puis citer pour chaque cas le numero de la clause d’exemption dans SyncOverAsyncConfigureAwaitAnalyzer.cs (le filtre syntaxique, le filtre semantique, ou l’absence de GetResult). Relier la clause citee au type de defense (anti-faux-positif).

// Exercice 1 -- prediction AVANT execution.
//
// Le terrain `SyncOverAsyncConfigureAwaitValueTask.cs` porte trois
// cas. Pour CHACUN, predire le verdict AGENTGUARD005b (rouge / propre)
// PUIS executer le Verifier sur le terrain et confronter :
//
//   var predits = new (string cas, string verdictAttendu, string clauseExemption)[]
//   {
//       ("ValueTaskSync", "PROPRE", "filtre semantique : ValueTask n'est pas Task"),
//       ("AvecCondition", "PROPRE", "filtre syntaxique : argument n'est pas un literal bool"),
//       ("SansGetResult", "PROPRE", "filtre syntaxique : pas de GetResult dans la chaine"),
//   };
//   foreach (var p in predits) Console.WriteLine($"{p.cas,-15} attendu={p.verdictAttendu,-7} | {p.clauseExemption}");
//
// L'analyseur distingue 3 systemes de defense orthogonaux. Une seule
// clause suffit a blanchir un cas -- ce qui rend les faux-positifs
// rares, mais pas gratuits : les nommer dans la reponse est la
// moitie de l'exercice.

// Note : le Verifier exige args.Length >= 2 (Program.cs:13 retourne
// exit 2 + banner usage sinon). On lance donc la paire fautif/valueTask :
// la fautive produit 2 diagnostics AGENTGUARD005b (preuve que
// l'analyseur fonctionne sur ConfigureAwait(false)/(true).GetAwaiter()
// .GetResult()) ; la valueTask produit 3 diagnostics PROPRE (preuve
// des 3 clauses d'exemption : ValueTask != Task, literal non-bool,
// pas de GetResult dans la chaine). La confrontation pedagogique
// predire/executer/verifier est donc possible sur le terrain.
var verdictsValueTask = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- " +
    "AgentGuard.Verifier/samples/SyncOverAsyncConfigureAwaitFautif.cs " +
    "AgentGuard.Verifier/samples/SyncOverAsyncConfigureAwaitValueTask.cs");
Console.WriteLine(verdictsValueTask);

Console.WriteLine("Exercice a completer : citer pour chaque cas la clause d'exemption gagnee");
[SyncOverAsyncConfigureAwaitFautif.cs] VERDICT : 2 diagnostic(s) -- AGENTGUARD005b x2
    AGENTGUARD005b @ 25:16  ConfigureAwait(False) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await
    AGENTGUARD005b @ 32:16  ConfigureAwait(True) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await
[SyncOverAsyncConfigureAwaitValueTask.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche

Exercice a completer : citer pour chaque cas la clause d'exemption gagnee

A3 — La thèse : dotnet build rend le diagnostic, sans aucun outil supplémentaire

Le projet AgentGuard.Demo référence l’analyseur comme dépendance de diagnostic — deux attributs dans le .csproj :

<ProjectReference Include="../AgentGuard.Analyzers/AgentGuard.Analyzers.csproj"
                  OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

OutputItemType="Analyzer" dit à MSBuild : « charge cette DLL comme analyseur Roslyn de CE projet ». ReferenceOutputAssembly="false" ajoute : « mais elle n’est pas une dépendance runtime ». C’est tout. Pas de paquet à installer, pas de ligne de commande à retenir, aucune action consciente à faire — le prochain dotnet build rend le verdict. C’est la thèse en action.

// Cette mesure rebuild le Demo avec la version courante des analyseurs.
// -t:Rebuild : force la recompilation complete -- un build incrementale
// (projet deja compile par un passage precedent) ne re-emet PAS les
// diagnostics, et le verdict doit apparaitre a CHAQUE execution.
// MSBuild rend des chemins absolus (machine-dependants) dans chaque
// diagnostic : on les abrege en chemin relatif au dossier de la serie
// (le suffixe [projet.csproj] garde son nom court) -- motif #11859.
var buildRaw = Shell.Run(here, "dotnet", "build AgentGuard.Demo -t:Rebuild -v q --nologo");
var build = string.Join(Environment.NewLine, buildRaw.Split('\n').Select(line =>
{
    var l = line.TrimEnd('\r');
    l = l.Replace(here + Path.DirectorySeparatorChar, "");
    // Suffixe [chemin\projet.csproj] -> [nom du projet]
    var open = l.LastIndexOf('[');
    if (open >= 0 && l.EndsWith(".csproj]"))
        l = l[..open] + $"[{Path.GetFileName(l[(open + 1)..^8])}]";
    return l;
}));
Console.WriteLine(build);
AgentGuard.Demo/Program.cs(24,22): warning AGENTGUARD001: Task bloquee de maniere synchrone (.Result) : deadlock potentiel en code d'agent [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(25,16): warning AGENTGUARD001: Task bloquee de maniere synchrone (.Result) : deadlock potentiel en code d'agent [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(55,30): warning AGENTGUARD002: La methode async void 'SurveillerCanalAsync' echappe a toute attente -- exceptions non observees, process mort [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(111,16): warning AGENTGUARD005: GetAwaiter().GetResult() bloque une Task/Task`1 de maniere synchrone : remplacer par await [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(88,15): warning AGENTGUARD004: L'appel à 'Task<string> AgentCancellation.CallLlmAsync(string prompt, CancellationToken cancellationToken = default(CancellationToken))' omet CancellationToken alors que 'cancellationToken' est disponible dans la méthode englobante [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(140,16): warning AGENTGUARD005b: ConfigureAwait(False) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(147,16): warning AGENTGUARD005b: ConfigureAwait(True) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(74,9): warning AGENTGUARD003: L'appel 'Task.Run(() => Console.WriteLine("run"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(75,9): warning AGENTGUARD003: L'appel 'Task.Factory.StartNew(() => Console.WriteLine("start"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse [AgentGuard.Demo]

La génération a réussi.

AgentGuard.Demo/Program.cs(24,22): warning AGENTGUARD001: Task bloquee de maniere synchrone (.Result) : deadlock potentiel en code d'agent [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(25,16): warning AGENTGUARD001: Task bloquee de maniere synchrone (.Result) : deadlock potentiel en code d'agent [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(55,30): warning AGENTGUARD002: La methode async void 'SurveillerCanalAsync' echappe a toute attente -- exceptions non observees, process mort [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(111,16): warning AGENTGUARD005: GetAwaiter().GetResult() bloque une Task/Task`1 de maniere synchrone : remplacer par await [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(88,15): warning AGENTGUARD004: L'appel à 'Task<string> AgentCancellation.CallLlmAsync(string prompt, CancellationToken cancellationToken = default(CancellationToken))' omet CancellationToken alors que 'cancellationToken' est disponible dans la méthode englobante [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(140,16): warning AGENTGUARD005b: ConfigureAwait(False) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(147,16): warning AGENTGUARD005b: ConfigureAwait(True) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(74,9): warning AGENTGUARD003: L'appel 'Task.Run(() => Console.WriteLine("run"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse [AgentGuard.Demo]
AgentGuard.Demo/Program.cs(75,9): warning AGENTGUARD003: L'appel 'Task.Factory.StartNew(() => Console.WriteLine("start"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse [AgentGuard.Demo]
    9 Avertissement(s)
    0 Erreur(s)

Temps écoulé 00:00:03.97

Lecture du verdict

La sortie du build porte neuf avertissements au total :

  • AGENTGUARD001 x2 — les deux .Result qui bloquent la lecture du canal puis l’appel LLM ;
  • AGENTGUARD002 x1 — la méthode async void SurveillerCanalAsync hors gestionnaire d’événement ;
  • AGENTGUARD003 x2 — Task.Run(...) et Task.Factory.StartNew(...) lancés comme énoncés autonomes ;
  • AGENTGUARD004 x1 — l’appel LLM omet le CancellationToken pourtant disponible ;
  • AGENTGUARD005 x1 et AGENTGUARD005b x2 — les trois formes de sync-over-async via GetAwaiter().GetResult().

Les six règles tirent dans le même build, sans configuration supplémentaire : chaque garde-fou ajouté au projet AgentGuard.Analyzers est automatiquement actif. Chacun nomme le fichier, la ligne, la colonne et le message complet. La ligne 0 Erreur(s) rappelle le choix de sévérité : le build réussit, le diagnostic est un garde-fou, pas un marteau.

Le point décisif : cette sortie est produite par dotnet build, la commande la plus ordinaire du monde .NET. Aucune mention d’outil externe, aucune étape de lint. Le diagnostic est arrivé avec la compilation elle-même — c’est ce que Python ne peut pas offrir par construction, et que la section C détaille.

B1 — Le canal API : le Verifier compile un source et rend son verdict

dotnet build est le canal intégration. Il en existe un second : l’API Roslyn elle-même — c’est ce qu’utilisent l’IDE (le soulignement jaune pendant la frappe) et les tests. Le projet AgentGuard.Verifier (committé) prend deux fichiers sources en argument, les compile en mémoire via CSharpCompilation + WithAnalyzers, et rend un verdict par fichier.

On lui soumet les deux variantes du terrain : le fichier fautif du Demo, et la version corrigée (samples/AgentWorkerCorrige.cs, committée — le même worker, mais async/await de bout en bout).

// Le verdict API porte sur le Demo courant et ses diagnostics attendus.
var verdicts = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- AgentGuard.Demo/Program.cs AgentGuard.Verifier/samples/AgentWorkerCorrige.cs");
Console.WriteLine(verdicts);
[Program.cs] VERDICT : 9 diagnostic(s) -- AGENTGUARD001 x2, AGENTGUARD002 x1, AGENTGUARD003 x2, AGENTGUARD004 x1, AGENTGUARD005 x1, AGENTGUARD005b x2
    AGENTGUARD001 @ 24:22  Task bloquee de maniere synchrone (.Result) : deadlock potentiel en code d'agent
    AGENTGUARD001 @ 25:16  Task bloquee de maniere synchrone (.Result) : deadlock potentiel en code d'agent
    AGENTGUARD002 @ 55:30  La methode async void 'SurveillerCanalAsync' echappe a toute attente -- exceptions non observees, process mort
    AGENTGUARD003 @ 74:9  L'appel 'Task.Run(() => Console.WriteLine("run"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse
    AGENTGUARD003 @ 75:9  L'appel 'Task.Factory.StartNew(() => Console.WriteLine("start"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse
    AGENTGUARD004 @ 88:15  L'appel à 'Task<string> AgentCancellation.CallLlmAsync(string prompt, CancellationToken cancellationToken = default(CancellationToken))' omet CancellationToken alors que 'cancellationToken' est disponible dans la méthode englobante
    AGENTGUARD005 @ 111:16  GetAwaiter().GetResult() bloque une Task/Task`1 de maniere synchrone : remplacer par await
    AGENTGUARD005b @ 140:16  ConfigureAwait(False) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await
    AGENTGUARD005b @ 147:16  ConfigureAwait(True) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await
[AgentWorkerCorrige.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche

Lecture des verdicts

Deux lignes de verdict, chacune adossée à son fichier :

  • Program.cs (le terrain fautif) : 9 diagnostics — AGENTGUARD001 x2, AGENTGUARD002 x1, AGENTGUARD003 x2, AGENTGUARD004 x1, AGENTGUARD005 x1 et AGENTGUARD005b x2 ;
  • AgentWorkerCorrige.cs (la version corrigée) : PROPRE, aucun garde-fou déclenché — son CancellationToken voyage déjà de bout en bout.

Deux canaux, un seul moteur : les analyseurs sont identiques ; ce qui change, c’est qui les héberge — MSBuild pour le canal build, l’API pour le canal tests/IDE. Un test d’intégration continue peut faire exactement ce que fait ce Verifier : compiler les sources générées par l’agent et exiger zéro diagnostic AgentGuard. Le garde-fou devient alors exécutable en CI sur du code qui n’existe même pas sous forme de projet — génération, compilation en mémoire, verdict, puis conservation ou régénération.

var corrige = File.ReadAllText(Path.Combine(here, "AgentGuard.Verifier", "samples", "AgentWorkerCorrige.cs"));
Console.WriteLine(corrige);
// Version corrigee du terrain fautif (AgentGuard.Demo/Program.cs) :
// le worker attend la tache au lieu de la bloquer. C'est la version
// attendue "propre" au verdict du Verifier.

using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public static class AgentWorkerCorrige
{
    // Le fix : async/await de bout en bout. Le thread rend la main pendant
    // l'attente au lieu de se bloquer -- plus de deadlock possible, et le
    // pipeline reste fluide sous charge.
    public static async Task<string> TranslateAsync(
        ChannelReader<string> inbound, CancellationToken ct = default)
    {
        var prompt = await inbound.ReadAsync(ct);
        return await CallLlmAsync(prompt, ct);
    }

    private static async Task<string> CallLlmAsync(string prompt, CancellationToken ct)
    {
        await Task.Delay(50, ct);       // simule la latence de l'appel LLM
        return $"[LLM] {prompt}";
    }
}

Le fix : await, et pourquoi il suffit

La version corrigée ne change pas la logique — seulement le contrat :

  • TranslateSync devient TranslateAsync : async Task<string> au lieu de string ;
  • chaque .Result devient un await : await inbound.ReadAsync(ct) et await CallLlmAsync(prompt, ct) ;
  • un CancellationToken voyage de bout en bout — bonus de cohérence avec la série.

Pourquoi le deadlock disparaît : await ne bloque pas le thread — il rend la main. Le thread retourne au pool, la continuation s’exécute quand la tâche finit, sur un thread disponible. Le pipeline reste fluide sous charge, et le verdict PROPRE du Verifier confirme que l’analyseur reconnaît la correction.

Quant au code fixer d’IDE (l’ampoule qui transformerait .Result en await d’un clic) : il vit côté IDE, invisible de dotnet build — le corriger automatiquement exige de réécrire la méthode englobante en async, une transformation trop contextuelle pour être montrée proprement ici. Le fix manuel ci-dessus est celui que l’ampoule elle-même proposerait.

Exercice 2 — Prouver l’exemption : un Outcome<T> monadique ne doit PAS être signalé

L’étage sémantique de l’analyseur (MetadataName is "Task" or "Task1”) existe pour éviter les faux positifs : un type fonctionnelOutcomequi expose une propriétéResult` est légal et non bloquant — l’analyseur ne doit pas le signaler.

Objectif : le prouver par l’exécution, puis expliquer la ligne qui fait l’exemption.

Indices : - # Indice : la cellule ci-dessous définit un Outcome<T> minimal avec une propriété Result — elle compile et s’exécute sans avertissement ; pourquoi le build du Demo n’a-t-il rien dit sur elle ? - # Etape 1 : écrire un source de test samples/exercice2.cs utilisant Outcome<string>.Result, et le passer au Verifier — verdict attendu : PROPRE. - # Etape 2 : dans TaskResultBlockAnalyzer.AnalyzeNode, identifier la ligne exacte qui évite le faux positif, et formuler en une phrase ce qu’elle vérifie. - # Etape 3 (pour aller plus loin) : supprimer mentalement cette ligne — quels nouveaux diagnostics apparaîtraient dans le corpus de la série ?

// Exercice 2 -- terrain : un Outcome<T> monadique avec propriete .Result.
// Ce type N'EST PAS une Task : bloquer n'a pas de sens ici, et l'analyseur
// ne le signale pas. TODO etudiant : verifier ce verdict via le Verifier.
public readonly record struct Outcome<T>(bool IsOk, T Value)
{
    public T Result => Value;   // propriete .Result SANS Task -- legal
}

var o = new Outcome<string>(true, "aucun blocage ici");
Console.WriteLine($"Outcome<string>.Result = {o.Result}");
Console.WriteLine("Exercice a completer : passer ce type au Verifier et nommer la ligne de l'exemption");
Outcome<string>.Result = aucun blocage ici
Exercice a completer : passer ce type au Verifier et nommer la ligne de l'exemption

C — Le contraste Python : le même défaut, deux écosystèmes

La classe de défaut est universelle : bloquer une tâche asynchrone depuis du code synchrone. En Python, une coroutine qui appelle une fonction bloquante (time.sleep, requests.get) produit exactement le même gel de l’événementiel. Voyons ce que chaque écosystème propose.

// Verifie sur cette machine : ruff est-il disponible sans installation ?
// Recherche dans le PATH : where sous Windows, which ailleurs.
var probe = Shell.Run(here, OperatingSystem.IsWindows() ? "where" : "which", "ruff");
var ruffPresent = probe.Contains("ruff", StringComparison.OrdinalIgnoreCase);
// Le chemin trouve depend de la machine : on n'en affiche que le nom de fichier.
Console.WriteLine(ruffPresent
    ? string.Join(Environment.NewLine, probe.Split('\n', StringSplitOptions.RemoveEmptyEntries)
        .Select(l => Path.GetFileName(l.TrimEnd('\r'))))
    : probe);
Console.WriteLine(ruffPresent
    ? "-> ruff present sur cette machine"
    : "-> ruff ABSENT : en Python le garde-fou est un outil a installer soi-meme");
ruff
-> ruff present sur cette machine

La comparaison, vérifiée

Selon la machine, la cellule ci-dessus trouve ruff ou non : il n’est là que si quelqu’un l’a installé. C’est déjà la moitié de la thèse : en Python, le garde-fou est un outil à adopter (pip install, config, pipeline). La règle qui couvrirait notre défaut existe et est documentée : ASYNC101 (famille flake8-async, intégrée à ruff) — « les fonctions async ne doivent pas appeler de méthodes synchrones bloquantes ». Vérifié contre la documentation ruff : cette règle fait partie des règles ASYNC, non activées par défaut — il faut les sélectionner explicitement ([tool.ruff.lint] select = ["ASYNC"]).

Aspect Python (ruff ASYNC101 / mypy) .NET (Roslyn AGENTGUARD001)
Où vit le garde-fou Hors compilation — outil distinct Dans la compilation
Adoption installer + configurer + ajouter au pipeline une ProjectReference
Activation opt-in par règle (select = ["ASYNC"]) isEnabledByDefault: true
Moment du verdict quand on lance le lint à chaque dotnet build
Compréhension des types partielle (analyse statique du source) totale (modèle sémantique du compilateur)
Faux positifs Result<T> possibles (analyse sans compilateur) évités (l’analyseur demande au compilateur)

La nuance honnête : l’écosystème Python compense par la richesse des règles prêtes à l’emploi (des centaines, maintenues par la communauté) là où notre analyseur est une règle maison. Le déplacement profond n’est pas la quantité mais le moment : en .NET, le diagnostic est constitutif de la chaîne qui produit le binaire — impossible de compiler sans passer devant le garde-fou.

D1 – AGENTGUARD005 livre : sync-over-async, deux variantes

Le code genere par agent attrape souvent la variante .GetAwaiter().GetResult() de la famille sync-over-async – exactement le meme defaut que .Result (AGENTGUARD001), mais emprunte par un chemin syntaxique distinct. L’agent voit un appel de methode ordinaire ; il ne se doute pas qu’il traverse la machine a etats d’une Task et bloque son thread.

Deux analyseurs sont livres dans AgentGuard.Analyzers/ :

  • SyncOverAsyncAnalyzer (AGENTGUARD005) : filtre syntaxique (GetAwaiter().GetResult()) plus filtre semantique (le receiver du GetAwaiter() est-il System.Threading.Tasks.Task ou Task<T> ?). Les awaiters personalises et les ValueTask sont exemptes par construction.
  • SyncOverAsyncConfigureAwaitAnalyzer (AGENTGUARD005b) : variante pedagogique dediee, capture tache.ConfigureAwait(bool).GetAwaiter().GetResult(). Le message diagnostique explique pourquoi ConfigureAwait(false) ne sauve pas : il reduit la capture du SynchronizationContext, mais .GetAwaiter().GetResult() bloque toujours le thread. Le sync-over-async reste entier.

D1 – les deux analyseurs, cote a cote. Sortie observee :

// --- borne semantique partagee (AGENTGUARD005 + 005b) ---
// MetadataName is not ("Task" or "Task`1") -> un awaiter custom n'est pas Task

// --- pivot distinctif de AGENTGUARD005b ---
// Le troisieme filtre de AGENTGUARD005b exige un literal bool :
//   if (cev.Arguments[0].Expression is not LiteralExpressionSyntax)
//       return;  -- pas un literal -> pas signale

// --- message diagnostique de AGENTGUARD005b ---
// Format string du Rule :
// "ConfigureAwait({0}) ne protege pas du sync-over-async :"

// --- diagnostic_id distincts ---
// AGENTGUARD005  : SyncOverAsyncAnalyzer.cs
// AGENTGUARD005b : SyncOverAsyncConfigureAwaitAnalyzer.cs
// D1 -- verdicts reels sur les 6 terrains SyncOverAsync committes.
// 5 terrains AGENTGUARD005 + 1 terrain AGENTGUARD005b. On observe le
// contraste entre les 3 rouges (fautifs) et les 3 propres (corriges /
// exemptes par le filtre semantique ou syntaxique).

var verdicts005 = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- " +
    "AgentGuard.Verifier/samples/SyncOverAsyncFautif.cs " +
    "AgentGuard.Verifier/samples/SyncOverAsyncCorrige.cs " +
    "AgentGuard.Verifier/samples/SyncOverAsyncGenericFautif.cs " +
    "AgentGuard.Verifier/samples/SyncOverAsyncSansGetResult.cs " +
    "AgentGuard.Verifier/samples/SyncOverAsyncAwaiterPersonnalise.cs " +
    "AgentGuard.Verifier/samples/SyncOverAsyncConfigureAwaitFautif.cs");
Console.WriteLine(verdicts005);
[SyncOverAsyncFautif.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD005 x1
    AGENTGUARD005 @ 21:9  GetAwaiter().GetResult() bloque une Task/Task de maniere synchrone : remplacer par await
[SyncOverAsyncCorrige.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[SyncOverAsyncGenericFautif.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD005 x1
    AGENTGUARD005 @ 21:16  GetAwaiter().GetResult() bloque une Task/Task`1 de maniere synchrone : remplacer par await
[SyncOverAsyncSansGetResult.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[SyncOverAsyncAwaiterPersonnalise.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[SyncOverAsyncConfigureAwaitFautif.cs] VERDICT : 2 diagnostic(s) -- AGENTGUARD005b x2
    AGENTGUARD005b @ 25:16  ConfigureAwait(False) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await
    AGENTGUARD005b @ 32:16  ConfigureAwait(True) ne protege pas du sync-over-async : .GetAwaiter().GetResult() bloque toujours le thread, remplacer par await

Lecture des verdicts

Les trois rouges sont tous du meme defaut (GetAwaiter().GetResult() synchrone sur une Task), portes par trois formes differentes : non-generique (SyncOverAsyncFautif), generique (SyncOverAsyncGenericFautif – Task<string>), avec ConfigureAwait (SyncOverAsyncConfigureAwaitFautif – qui declenche AGENTGUARD005b mais pas AGENTGUARD005, parce que le receiver du GetAwaiter est ConfiguredTaskAwaitable, pas Task).

Les trois propres relevent des trois clauses d’exemption :

  • SyncOverAsyncCorrige : methode async + await. Plus de chaine fautive – l’analyseur laisse passer.
  • SyncOverAsyncSansGetResult : la chaine accede a IsCompleted, pas a GetResult(). Filtre syntaxique du membre invoque -> l’analyseur ne se declenche pas.
  • SyncOverAsyncAwaiterPersonnalise : MonAwaitable.GetAwaiter().GetResult(). Le filtre semantique verifie que le receiver du GetAwaiter() est System.Threading.Tasks.Task ; ici c’est MonAwaitable. Pas de diagnostic.

Le message d’AGENTGUARD005b est explicite (contrairement a AGENTGUARD001) : il dit “ConfigureAwait(false) ne protege pas du sync-over-async”. C’est la valeur d’un analyseur dedie plutot qu’une regle : expliquer, pas seulement hurler.

D — Le réflexe pour du code généré par agent

Résumé du geste complet, tel qu’un pipeline d’agent .NET peut l’adopter :

  1. Un analyseur par classe de défaut — ici le blocage de Task ; demain l’appel HTTP sans timeout (leçon du notebook 01), le secret en dur (leçon de la série sécurité), la désérialisation non bornée.
  2. Référencé dans le projet — OutputItemType="Analyzer" : chaque dotnet build, de chaque machine, rend le verdict. L’agent qui génère du code reçoit le diagnostic dans la sortie de son propre build — boucle de correction immédiate, sans review humain dans la boucle.
  3. Exécutable en CI sur du code volatile — le canal API (Verifier) permet de filtrer le code généré avant même qu’il n’atteigne un projet : génération → compilation en mémoire → verdict → garder ou régénérer.

C’est la réponse .NET à la question du garde-fou de code généré : ne pas ajouter un outil à côté du compilateur — mettre la règle dans le compilateur.

D2 — AGENTGUARD002 livré : async void hors gestionnaire, l’exemption démontrée

Second pattern d’agent classique : async void FaitUneChose() — la méthode ressemble à une async Task, mais ses exceptions échappent à tout mécanisme d’attente : non observables, elles font planter le process ; la tâche n’est pas attendable, donc non testable.

Ce que l’exercice 3 de la première version demandait d’écrire est désormais livré : AgentGuard.Analyzers contient l’analyseur réel (AsyncVoidAnalyzer), le terrain fautif vit dans le Demo (AgentFireAndForget.SurveillerCanalAsync — déjà signalé par le build de la section A3), et trois sources de test sont committées côté Verifier. La question pédagogique se déplace : ce qui est intéressant maintenant n’est plus « écrire le filtre » mais l’exemption — comment distinguer un async void fautif d’un gestionnaire d’événement légitime, dont le contrat C# EXIGE void ?

La clé : l’exemption est sémantique, pas lexicale. On ne regarde pas le nom du paramètre (sender ne prouve rien) — on demande au compilateur si la signature est (object, T) avec T qui dérive de System.EventArgs.

// D2 : l'analyseur AGENTGUARD002 en action -- trois terrains, trois verdicts.
// D'abord l'exemption, telle qu'ecrite dans AsyncVoidAnalyzer.cs :
var analyzerPath = Path.Combine(here, "AgentGuard.Analyzers", "AsyncVoidAnalyzer.cs");
var analyzerSrc = File.ReadAllText(analyzerPath);
var start = analyzerSrc.IndexOf("private static bool EstHandlerEvenement");
Console.WriteLine("// --- l'exemption, extraite de AsyncVoidAnalyzer.cs ---");
Console.WriteLine(analyzerSrc[start..analyzerSrc.IndexOf("private static bool EstEventArgsOuDerive")].TrimEnd());

// Puis les trois terrains passes au Verifier (canal API) :
var verdicts002 = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- " +
    "AgentGuard.Verifier/samples/AsyncVoidFautif.cs " +
    "AgentGuard.Verifier/samples/AsyncVoidCorrige.cs " +
    "AgentGuard.Verifier/samples/AsyncVoidHandlerExempt.cs");
Console.WriteLine();
Console.WriteLine(verdicts002);
// --- l'exemption, extraite de AsyncVoidAnalyzer.cs ---
private static bool EstHandlerEvenement(MethodDeclarationSyntax method, SyntaxNodeAnalysisContext ctx)
    {
        var parameters = method.ParameterList.Parameters;
        if (parameters.Count < 2) return false;

        // Premier parametre exactement object (le "sender").
        var senderType = ctx.SemanticModel.GetTypeInfo(parameters[0].Type).Type;
        if (senderType?.SpecialType != SpecialType.System_Object) return false;

        // Second parametre derive de System.EventArgs (ou l'est lui-meme).
        var argsType = ctx.SemanticModel.GetTypeInfo(parameters[1].Type).Type;
        if (argsType is null || argsType.TypeKind == TypeKind.Error) return false;
        return EstEventArgsOuDerive(argsType, ctx.Compilation);
    }

[AsyncVoidFautif.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD002 x1
    AGENTGUARD002 @ 13:30  La methode async void 'TraiterCommande' echappe a toute attente -- exceptions non observees, process mort
[AsyncVoidCorrige.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[AsyncVoidHandlerExempt.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche

Lecture : l’exemption qui fait la différence entre un garde-fou et un gêneur

Trois verdicts, et le troisième est le plus important :

  • AsyncVoidFautif.cs : AGENTGUARD002 x1 — TraiterCommande(string) n’a pas de signature de handler, elle est signalée à sa position exacte ;
  • AsyncVoidCorrige.cs : PROPRE — async Task au lieu d’async void, la méthode redevient attendable, composable, testable ;
  • AsyncVoidHandlerExempt.cs : PROPRE — deux async void pourtant bien présents (OnTimerElapsed, OnProgressReported), et aucun signal. C’est l’exemption au travail : le premier reçoit (object, EventArgs) — la forme canonique ; le second (object, ProgressEventArgs) — un EventArgs dérivé, dont l’héritage est remonté par le modèle sémantique (BaseType en boucle, dans EstEventArgsOuDerive). Un grep sur async void ne peut pas faire cette distinction ; un lint sans compilateur doit se fier au nom des paramètres. L’analyseur demande au compilateur.

C’est le même écart de précision que l’exemption Result<T> de l’exercice 2, sur le second analyseur : l’étage sémantique est ce que .NET achète contre tout outillage externe.

Exemple guidé 3 — AGENTGUARD003 : Task.Run feu, la tâche non observée

Troisième pattern d’agent : le « lance et oublie » correct en apparence — Task.Run(() => ...). Contrairement à async void, la signature est honnête (une Task est rendue), mais le code généré l’ignore (_ = absent, aucune variable, aucun await). La tâche s’exécute et personne n’observe ses exceptions.

L’analyseur livré suit deux étages :

  1. il résout sémantiquement la méthode appelée et vérifie son appartenance aux types BCL ciblés ;
  2. il ne signale que l’invocation dont le parent direct est un ExpressionStatementSyntax.

La cellule suivante affiche le source réel de TaskRunFireAnalyzer.cs. La matrice de terrains située après la conclusion provisoire vérifiera ensuite le cas fautif, les formes observées et les homonymes.

// Exemple guide resolu -- l'analyseur AGENTGUARD003 livre dans
// `AgentGuard.Analyzers/TaskRunFireAnalyzer.cs`, sur le meme squelette a
// deux etages que ses predecesseurs : semantique (Task.Run ou les fabriques
// TaskFactory.StartNew BCL), puis syntaxique (parent == ExpressionStatement).
var analyzerSrc = File.ReadAllText(Path.Combine(here, "AgentGuard.Analyzers", "TaskRunFireAnalyzer.cs"));
Console.WriteLine(analyzerSrc);
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace AgentGuard.Analyzers;

/// <summary>
/// AGENTGUARD003 : invocation nue d'une fabrique de Task, tache non observee.
///
/// Troisieme pattern typique du code genere par agent : ecrire
/// `Task.Run(() => Travail())`, `Task.Factory.StartNew(() => Travail())`
/// ou la variante generique via `Task<TResult>.Factory` comme enonce autonome.
/// La signature est
/// honnete (la methode rend une Task, pas void), MAIS la tache resultante
/// n'est ni attendue (await), ni affectee a une variable, ni retournee,
/// ni explicitement ignoree via discard (`_ =`). Elle s'execute en arriere-
/// plan ; ses exceptions ne sont observees par personne. A la finalisation
/// d'une telle tache fautive, le runtime declenche
/// `TaskScheduler.UnobservedTaskException`, un evenement qui porte une
/// `AggregateException` collectant les exceptions internes -- le defaut
/// (.NET 4.5+) est d'absorber l'evenement et de laisser le process vivre,
/// mais ce comportement est configurable et n'est pas garanti.
///
/// Formes LEGITIMES (a ne PAS signaler) :
///   - `await Task.Run(...)`                    -- tache observee
///   - `var t = Task.Factory.StartNew(...)`     -- tache recuperee
///   - `_ = Task.Factory.StartNew(...)`         -- discard explicite
///   - `return Task.Run(...)`                   -- tache retournee
///   - homonyme custom (autre type, autre signature) -- filtre semantique
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class TaskRunFireAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "AGENTGUARD003";

    private static readonly DiagnosticDescriptor Rule = new(
        DiagnosticId,
        "Fabrique de Task nue, tache non observee",
        "L'appel '{0}' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse",
        "Agentisme",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Une invocation nue de Task.Run, Task.Factory.StartNew ou Task<TResult>.Factory.StartNew execute la tache en arriere-plan ; ses exceptions ne sont observees par personne. Utiliser await, affecter a une variable, retourner ou discarder explicitement (_ =).");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        // On s'abonne aux INVOCATIONS (Task.Run(...) ou StartNew(...)). Le
        // diagnostic porte sur l'appel complet et non sur un simple acces de
        // membre : la strategie d'AGENTGUARD001 ne s'applique donc pas ici.
        context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
    }

    private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx)
    {
        var inv = (InvocationExpressionSyntax)ctx.Node;

        // 1. Filtre semantique : la methode invoquee est exactement Task.Run
        //    ou TaskFactory.StartNew. Les proprietes Task.Factory et
        //    Task<TResult>.Factory rendent respectivement TaskFactory et
        //    TaskFactory<TResult> : StartNew est defini sur ces fabriques,
        //    pas sur Task.
        //    Les noms seuls ne comptent pas : le namespace et le type contenant
        //    evincent MonService.Run et une TaskFactory homonyme.
        if (ctx.SemanticModel.GetSymbolInfo(inv).Symbol is not IMethodSymbol method) return;
        if (method.ContainingType is not INamedTypeSymbol ct
            || ct.ContainingNamespace?.ToDisplayString() != "System.Threading.Tasks") return;

        var isTaskRun = ct.MetadataName == "Task" && method.MetadataName == "Run";
        var isTaskFactoryStartNew = ct.MetadataName is "TaskFactory" or "TaskFactory`1"
            && method.MetadataName == "StartNew";
        if (!isTaskRun && !isTaskFactoryStartNew) return;

        // 2. Filtre syntaxique : la seule forme signalee est l'ExpressionStatement
        //    nu (l'invocation est l'integralite de l'enonce). Les autres formes
        //    -- await, affectation, discard, return, argument d'une autre
        //    invocation -- ne sont JAMAIS signalees.
        //
        //    Cas particulier `_ = Task.Run(...)` : Roslyn represente le discard
        //    comme un AssignmentExpression avec Left = IdentifierName("_"),
        //    donc le parent n'est PAS un ExpressionStatement nu -- il est
        //    rattrape par le filtre "n'est pas ExpressionStatement" et tombe
        //    naturellement dans la branche exempt. Le test terrain le verifie.
        if (inv.Parent is not ExpressionStatementSyntax) return;

        ctx.ReportDiagnostic(Diagnostic.Create(
            Rule, inv.GetLocation(), inv.ToString()));
    }
}

Mesure — compatibilité Task.Run et extension StartNew

Le premier passage conserve les cinq terrains historiques Task.Run : un fautif et quatre contrôles négatifs. Le passage guidé qui suit ajoute les fabriques StartNew, sans modifier ces verdicts de référence.

// Exemple guide resolu -- cliquet historique AGENTGUARD003.
// Cinq terrains Task.Run : un fautif, puis quatre formes propres
// (await, affectation+retour, discard et homonyme utilisateur).
var verdicts003 = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- " +
    "AgentGuard.Verifier/samples/TaskRunFireFautif.cs " +
    "AgentGuard.Verifier/samples/TaskRunFireCorrige.cs " +
    "AgentGuard.Verifier/samples/TaskRunFireAssignation.cs " +
    "AgentGuard.Verifier/samples/TaskRunFireDiscard.cs " +
    "AgentGuard.Verifier/samples/TaskRunFireHomonyme.cs");
Console.WriteLine(verdicts003);
[TaskRunFireFautif.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD003 x1
    AGENTGUARD003 @ 14:9  L'appel 'Task.Run(() => Console.WriteLine("ping"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse
[TaskRunFireCorrige.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[TaskRunFireAssignation.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[TaskRunFireDiscard.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[TaskRunFireHomonyme.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche

Lecture des verdicts historiques

Les cinq terrains Task.Run conservent exactement leurs verdicts :

  • TaskRunFireFautif.cs : AGENTGUARD003 x1 sur Task.Run(...) nu ;
  • TaskRunFireCorrige.cs : PROPRE avec await ;
  • TaskRunFireAssignation.cs : PROPRE avec affectation puis retour ;
  • TaskRunFireDiscard.cs : PROPRE avec _ = ;
  • TaskRunFireHomonyme.cs : PROPRE, car MonRunner.Run n’appartient pas à System.Threading.Tasks.Task.

Cette matrice est le cliquet de compatibilité de l’extension : ajouter StartNew ne doit ni perdre le diagnostic historique, ni élargir les faux positifs.

Exemple guidé 4 — Étendre AGENTGUARD003 à Task.Factory.StartNew(...) nu

Task.Run(...) n’est pas la seule fabrique de tâches qu’un agent peut abandonner. Les propriétés Task.Factory et Task<TResult>.Factory rendent respectivement une TaskFactory et une TaskFactory<TResult> ; c’est sur ces deux types que Roslyn résout la méthode StartNew.

L’extension conserve le même diagnostic AGENTGUARD003, car la classe de défaut ne change pas : une tâche est créée puis jetée comme énoncé autonome. Le filtre reste sémantique :

  1. résoudre l’IMethodSymbol de l’invocation ;
  2. accepter Task.Run, TaskFactory.StartNew ou TaskFactory<TResult>.StartNew dans System.Threading.Tasks ;
  3. signaler uniquement si l’invocation est directement un ExpressionStatementSyntax.

Cette dernière condition conserve naturellement les exemptions : await, affectation, discard _ = et return placent l’invocation sous un autre parent syntaxique. Un type utilisateur nommé TaskFactory reste propre, car son symbole n’appartient pas au namespace BCL.

La cellule suivante exécute quatre terrains committés : deux fautifs (fabriques non générique et générique), un groupe d’usages observés et un homonyme utilisateur.

// Exemple guide resolu -- AGENTGUARD003 sur les fabriques StartNew.
var verdictsStartNew003 = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- " +
    "AgentGuard.Verifier/samples/TaskFactoryStartNewNude.cs " +
    "AgentGuard.Verifier/samples/TaskFactoryStartNewObserve.cs " +
    "AgentGuard.Verifier/samples/TaskFactoryStartNewHomonyme.cs " +
    "AgentGuard.Verifier/samples/TaskFactoryStartNewGenerique.cs");
Console.WriteLine(verdictsStartNew003);
[TaskFactoryStartNewNude.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD003 x1
    AGENTGUARD003 @ 11:9  L'appel 'Task.Factory.StartNew(() => Console.WriteLine("ping"))' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse
[TaskFactoryStartNewObserve.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[TaskFactoryStartNewHomonyme.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[TaskFactoryStartNewGenerique.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD003 x1
    AGENTGUARD003 @ 11:9  L'appel 'Task<int>.Factory.StartNew(() => 42)' lance une tache non observee -- exceptions potentiellement perdues, defaillance silencieuse

Lecture du résultat

Les deux énoncés autonomes rendent chacun AGENTGUARD003 x1 : la forme non générique via Task.Factory et la forme générique via Task<int>.Factory. Le groupe d’usages observés reste PROPRE pour await, l’affectation suivie d’un retour et le discard explicite. L’homonyme utilisateur reste également PROPRE.

La paire fautif/propre montre les deux bornes du diagnostic : le symbole doit être une vraie fabrique BCL, puis son résultat doit être abandonné comme énoncé autonome. Reconnaître le nom StartNew seul aurait signalé l’homonyme ; reconnaître le type seul aurait signalé les tâches correctement observées.

Exercice 4 — Borner l’exemption générique

Le terrain fautif prouve que Task<TResult>.Factory.StartNew(...) est reconnu. Il reste à démontrer symétriquement que la variante générique observée demeure propre.

Objectif : ajouter un terrain TaskFactoryStartNewGeneriqueObserve.cs qui récupère ou attend la tâche générique, puis prédire et vérifier son verdict.

  • # Etape 1 : choisir une seule forme d’observation (await, affectation, discard ou return) et expliquer son parent syntaxique ;
  • # Etape 2 : passer le terrain au Verifier avec un terrain fautif comme contrôle positif ;
  • # Indice : le type contenant doit toujours être TaskFactory<TResult> ; seule la position syntaxique de l’invocation doit blanchir le cas ;
  • # TODO etudiant : comparer le verdict obtenu au filtre inv.Parent is not ExpressionStatementSyntax.
// Exercice 4 a completer -- concevoir un controle negatif generique.
// TODO etudiant : ajouter un sample ou TaskFactory<int>.StartNew est
// observe par await, affectation, discard ou return, puis predire le verdict.
// Indice : le filtre doit reconnaitre TaskFactory<TResult> sans signaler
// une tache dont le parent syntaxique n'est pas un ExpressionStatement nu.
Console.WriteLine("Exercice a completer : borner l'exemption generique de StartNew");
Exercice a completer : borner l'exemption generique de StartNew

D3 — AGENTGUARD004 : l’annulation doit traverser toute la chaîne

Un agent reçoit souvent un CancellationToken depuis une requête HTTP ou un BackgroundService. Le token n’est utile que s’il voyage jusqu’à chaque opération annulable. Le défaut est discret : la méthode englobante possède bien le token, la cible l’accepte, mais l’appel omet l’argument optionnel. Le code compile et continue à travailler après l’annulation demandée.

AGENTGUARD004 vise exactement cette rupture. Il ne cherche ni un nom comme Async, ni un paramètre nommé ct : il demande au modèle sémantique si le symbole englobant possède un vrai System.Threading.CancellationToken, puis si la signature cible expose ce même type et si l’argument a été fourni explicitement.

// Exemple guide resolu -- lire l'analyseur semantique livre.
var analyzer004 = File.ReadAllText(Path.Combine(
    here, "AgentGuard.Analyzers", "CancellationTokenPropagationAnalyzer.cs"));
Console.WriteLine(analyzer004);
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace AgentGuard.Analyzers;

/// <summary>
/// AGENTGUARD004 : perte d'un CancellationToken disponible.
///
/// Une méthode d'agent reçoit souvent un token depuis la requête HTTP ou le
/// BackgroundService. Si elle appelle une opération dont la signature expose
/// explicitement CancellationToken mais omet cet argument optionnel, l'arrêt
/// demandé ne traverse plus la chaîne. L'analyse est entièrement sémantique :
/// le type doit être System.Threading.CancellationToken, sans heuristique sur
/// le nom de la méthode ni du paramètre.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class CancellationTokenPropagationAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "AGENTGUARD004";

    private static readonly DiagnosticDescriptor Rule = new(
        DiagnosticId,
        "CancellationToken disponible mais non propage",
        "L'appel à '{0}' omet CancellationToken alors que '{1}' est disponible dans la méthode englobante",
        "Agentisme",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Propager le CancellationToken disponible aux opérations annulables afin que l'arrêt traverse toute la chaîne d'agent.");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
    }

    private static void AnalyzeInvocation(OperationAnalysisContext ctx)
    {
        var invocation = (IInvocationOperation)ctx.Operation;
        var cancellationToken = ctx.Compilation.GetTypeByMetadataName(
            "System.Threading.CancellationToken");
        if (cancellationToken is null) return;

        var tokenParameter = invocation.TargetMethod.Parameters.FirstOrDefault(
            parameter => SymbolEqualityComparer.Default.Equals(
                parameter.Type, cancellationToken));
        if (tokenParameter is null) return;

        // Roslyn matérialise un argument optionnel omis avec ArgumentKind.DefaultValue.
        // Seul un argument Explicit prouve que l'appelant a propagé un token.
        var tokenIsExplicit = invocation.Arguments.Any(argument =>
            SymbolEqualityComparer.Default.Equals(argument.Parameter, tokenParameter)
            && argument.ArgumentKind == ArgumentKind.Explicit);
        if (tokenIsExplicit) return;

        var availableToken = FindAvailableToken(ctx.ContainingSymbol, cancellationToken);
        if (availableToken is null) return;

        ctx.ReportDiagnostic(Diagnostic.Create(
            Rule,
            invocation.Syntax.GetLocation(),
            invocation.TargetMethod.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
            availableToken.Name));
    }

    private static IParameterSymbol FindAvailableToken(
        ISymbol symbol,
        INamedTypeSymbol cancellationToken)
    {
        // Une lambda ou une fonction locale peut capturer le token de sa méthode
        // englobante : on remonte donc la chaîne de symboles, sans sortir du type.
        for (var current = symbol; current is not null && current is not INamedTypeSymbol;
             current = current.ContainingSymbol)
        {
            if (current is not IMethodSymbol method) continue;

            var token = method.Parameters.FirstOrDefault(parameter =>
                SymbolEqualityComparer.Default.Equals(
                    parameter.Type, cancellationToken));
            if (token is not null) return token;
        }

        return null;
    }
}

Mesure — un fautif, quatre contrôles négatifs

Le Verifier soumet cinq sources au même analyseur :

  1. token disponible + cible annulable + argument omis : rouge ;
  2. token transmis : propre ;
  3. aucun token disponible : propre ;
  4. surcharge réellement choisie sans paramètre token : propre ;
  5. type homonyme AgentCustom.CancellationToken : propre.

Ce dernier contrôle est décisif : le filtre compare les symboles au type BCL System.Threading.CancellationToken, pas leur simple orthographe.

var verdicts004 = Shell.Run(here, "dotnet",
    "run --project AgentGuard.Verifier -- " +
    "AgentGuard.Verifier/samples/CancellationTokenFautif.cs " +
    "AgentGuard.Verifier/samples/CancellationTokenCorrige.cs " +
    "AgentGuard.Verifier/samples/CancellationTokenSansTokenDisponible.cs " +
    "AgentGuard.Verifier/samples/CancellationTokenSurchargeSansToken.cs " +
    "AgentGuard.Verifier/samples/CancellationTokenHomonyme.cs");
Console.WriteLine(verdicts004);
[CancellationTokenFautif.cs] VERDICT : 1 diagnostic(s) -- AGENTGUARD004 x1
    AGENTGUARD004 @ 10:15  L'appel à 'Task AgentCancellationFautif.EnvoyerAuModeleAsync(string prompt, CancellationToken cancellationToken = default(CancellationToken))' omet CancellationToken alors que 'cancellationToken' est disponible dans la méthode englobante
[CancellationTokenCorrige.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[CancellationTokenSansTokenDisponible.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[CancellationTokenSurchargeSansToken.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche
[CancellationTokenHomonyme.cs] VERDICT : PROPRE -- aucun garde-fou AgentGuard declenche

Lecture du résultat

Le terrain fautif rend exactement AGENTGUARD004 x1 sur l’appel qui perd l’annulation. Les quatre autres terrains rendent PROPRE : le diagnostic disparaît dès que le vrai token est transmis, et il n’apparaît pas quand le contrat ou la portée ne permettent aucune propagation.

La distinction « surcharge sans token » évite une recommandation impossible : si la méthode réellement résolue par Roslyn n’expose pas CancellationToken, l’analyseur ne prétend pas qu’un argument pourrait lui être ajouté. La distinction « homonyme » évite le faux positif lexical. Ces résultats mesurés bornent précisément ce que garantit la règle : propager un token déjà disponible vers une cible qui l’accepte explicitement.

Exercice 5 — Borner la portée dans une fonction locale

L’analyseur remonte les symboles englobants : une fonction locale ou une lambda peut capturer le token de sa méthode parente. Cette propriété évite de perdre l’annulation dans un callback interne, mais elle doit rester précise.

Objectif : écrire un terrain qui prouve la capture, puis un contre-exemple où un token local est déjà transmis.

  • # Etape 1 : dans une méthode TraiterAsync(CancellationToken ct), déclarer une fonction locale EtapeAsync() qui appelle une cible annulable sans ct ; verdict attendu : AGENTGUARD004.
  • # Etape 2 : transmettre ct depuis la fonction locale ; verdict attendu : PROPRE.
  • # Indice : ne modifiez pas les noms des méthodes pour aider l’analyseur — il ne les utilise pas.
  • # TODO etudiant : ajouter les deux sources aux samples du Verifier et expliquer quel ContainingSymbol rend la capture visible.
// Exercice 5 a completer -- terrain minimal de fonction locale.
// TODO etudiant : transformer ce commentaire en deux samples du Verifier.
//
// static async Task TraiterAsync(CancellationToken ct)
// {
//     async Task EtapeAsync()
//     {
//         await OperationAnnulableAsync();      // attendu : AGENTGUARD004
//         // puis corriger avec OperationAnnulableAsync(ct)
//     }
//     await EtapeAsync();
// }
Console.WriteLine("Exercice a completer : verifier la propagation dans une fonction locale");
Exercice a completer : verifier la propagation dans une fonction locale

Conclusion

Ce notebook a démontré, exécution à l’appui :

  • six règles Roslyn réelles couvrent le blocage synchrone, async void, les tâches non observées, la perte de CancellationToken et deux formes de GetAwaiter().GetResult() ;
  • AGENTGUARD003 reconnaît sémantiquement Task.Run, Task.Factory.StartNew et Task<TResult>.Factory.StartNew nus, tout en conservant les exemptions d’observation et les homonymes utilisateur ;
  • sur le canal build, dotnet build rend les diagnostics sans outil supplémentaire — la thèse DANS-la-compilation ;
  • sur le canal API, le Verifier compile les terrains fautifs, corrigés et homonymes, puis rend les mêmes diagnostics que l’IDE et MSBuild ;
  • chaque analyseur combine un périmètre précis avec des exemptions exécutées, plutôt qu’une heuristique textuelle.

Les exemples guidés matérialisent les capacités déjà livrées. Quatre exercices non résolus conservent le travail étudiant : prédire les exemptions de ConfigureAwait, éprouver un Outcome<T> homonyme, borner l’exemption générique de StartNew et vérifier la propagation d’un token dans une fonction locale.

Pour aller plus loin : la série Aspire fournit le terrain d’agent ; l’Epic #10473 tient la parité Python/C# des garde-fous ; et la documentation Roslyn DiagnosticAnalyzer couvre les code fixers et les tests (Microsoft.CodeAnalysis.Analyzer.Testing).

See #10473

Retour au sommet