@@ -61,6 +61,8 @@ public override void Initialize(AnalysisContext context)
6161
6262 context . RegisterSymbolAction ( AnalyzeMethod , SymbolKind . Method ) ;
6363 context . RegisterOperationAction ( AnalyzeAnonymousFunction , OperationKind . AnonymousFunction ) ;
64+
65+ context . RegisterOperationAction ( AnalyzeMinimalApiInvocation , OperationKind . Invocation ) ;
6466 }
6567
6668 private static void AnalyzeMethod ( SymbolAnalysisContext context )
@@ -117,6 +119,7 @@ private static void AnalyzeAnonymousFunction(OperationAnalysisContext context)
117119 var anon = ( IAnonymousFunctionOperation ) context . Operation ;
118120 var symbol = anon . Symbol ;
119121
122+ // Only Task / ValueTask
120123 if ( symbol . ReturnType is not INamedTypeSymbol returnType )
121124 {
122125 return ;
@@ -127,15 +130,43 @@ private static void AnalyzeAnonymousFunction(OperationAnalysisContext context)
127130 return ;
128131 }
129132
133+ // For lambdas/anonymous functions we do NOT enforce "must have CT" (PT0002),
134+ // because the delegate signature is usually dictated by an external API
135+ // (Hangfire, ASP.NET, minimal APIs binder, etc.).
136+ // We only normalize name and position if a CT parameter already exists.
137+ var ctInfo = GetCancellationTokenInfo ( symbol ) ;
138+ if ( ! ctInfo . HasCt )
139+ {
140+ return ;
141+ }
142+
130143 const string displayName = "anonymous function" ;
144+ var location = anon . Syntax . GetLocation ( ) ;
131145
132- AnalyzeAsyncMember (
133- symbol ,
134- anon . Syntax . GetLocation ( ) ,
135- displayName ,
136- context . ReportDiagnostic ) ;
146+ // PT0003: name must be ct
147+ if ( ! ctInfo . IsNamedCt )
148+ {
149+ context . ReportDiagnostic (
150+ Diagnostic . Create (
151+ CancellationTokenNameRule ,
152+ location ,
153+ displayName ,
154+ ctInfo . Name ) ) ;
155+ }
156+
157+ // PT0004: CT must be last non-params parameter
158+ if ( ! ctInfo . IsLast )
159+ {
160+ context . ReportDiagnostic (
161+ Diagnostic . Create (
162+ CancellationTokenPositionRule ,
163+ location ,
164+ displayName ,
165+ ctInfo . Name ) ) ;
166+ }
137167 }
138168
169+
139170 private static void AnalyzeAsyncMember ( IMethodSymbol method ,
140171 Location location ,
141172 string displayName ,
@@ -240,6 +271,129 @@ private static CtInfo GetCancellationTokenInfo(IMethodSymbol method)
240271 return new CtInfo ( true , isNamedCt , isLast , ctParam . Name ) ;
241272 }
242273
274+ private static void AnalyzeMinimalApiInvocation ( OperationAnalysisContext context )
275+ {
276+ var invocation = ( IInvocationOperation ) context . Operation ;
277+ var target = invocation . TargetMethod ;
278+
279+ // 1. Is this one of the Minimal API Map* extension methods?
280+ if ( ! IsMinimalApiMapMethod ( target ) )
281+ {
282+ return ;
283+ }
284+
285+ // 2. Find the handler lambda inside the arguments.
286+ // We do NOT rely on arg.Parameter.Type (which is often System.Delegate).
287+ IAnonymousFunctionOperation ? handlerAnon = null ;
288+
289+ foreach ( var arg in invocation . Arguments )
290+ {
291+ handlerAnon = ExtractAnonymousFunction ( arg . Value ) ;
292+ if ( handlerAnon is not null )
293+ {
294+ break ;
295+ }
296+ }
297+
298+ if ( handlerAnon is null )
299+ {
300+ // Handler is a method group or something non-lambda; skip.
301+ return ;
302+ }
303+
304+ var handlerSymbol = handlerAnon . Symbol ;
305+
306+ // Must be Task/ValueTask-based handler.
307+ if ( handlerSymbol . ReturnType is not INamedTypeSymbol returnType ||
308+ ! IsTaskLike ( returnType ) )
309+ {
310+ return ;
311+ }
312+
313+ // 3. Enforce: minimal API handler must have a CancellationToken parameter.
314+ var ctInfo = GetCancellationTokenInfo ( handlerSymbol ) ;
315+ if ( ctInfo . HasCt )
316+ {
317+ // If CT already exists, PT0003/PT0004 are handled by AnalyzeAnonymousFunction.
318+ return ;
319+ }
320+
321+ const string displayName = "anonymous function" ;
322+
323+ context . ReportDiagnostic (
324+ Diagnostic . Create (
325+ CancellationTokenMissingRule ,
326+ handlerAnon . Syntax . GetLocation ( ) ,
327+ displayName ) ) ;
328+ }
329+
330+
331+ private static bool IsMinimalApiMapMethod ( IMethodSymbol method )
332+ {
333+ if ( ! method . IsExtensionMethod )
334+ {
335+ return false ;
336+ }
337+
338+ // EndpointRouteBuilderExtensions.MapGet / MapPost / MapPut / MapDelete / MapMethods / ...
339+ var containingType = method . ContainingType ;
340+ if ( containingType is null )
341+ {
342+ return false ;
343+ }
344+
345+ if ( ! string . Equals ( containingType . Name , "EndpointRouteBuilderExtensions" , StringComparison . Ordinal ) )
346+ {
347+ return false ;
348+ }
349+
350+ var ns = containingType . ContainingNamespace . ToDisplayString ( ) ;
351+ if ( ! ns . StartsWith ( "Microsoft.AspNetCore.Builder" , StringComparison . Ordinal ) )
352+ {
353+ return false ;
354+ }
355+
356+ // Limit to the typical Map* names
357+ return method . Name is
358+ "MapGet" or
359+ "MapPost" or
360+ "MapPut" or
361+ "MapDelete" or
362+ "MapPatch" or
363+ "MapHead" or
364+ "MapOptions" or
365+ "MapTrace" or
366+ "MapMethods" ;
367+ }
368+
369+
370+ private static IAnonymousFunctionOperation ? ExtractAnonymousFunction ( IOperation value )
371+ {
372+ while ( true )
373+ {
374+ switch ( value )
375+ {
376+ case IAnonymousFunctionOperation anon :
377+ return anon ;
378+
379+ case IDelegateCreationOperation
380+ {
381+ Target : IAnonymousFunctionOperation anon
382+ } :
383+ return anon ;
384+
385+ case IConversionOperation
386+ {
387+ Operand : var operand
388+ } :
389+ value = operand ;
390+ continue ;
391+
392+ default :
393+ return null ;
394+ }
395+ }
396+ }
243397
244398 private readonly struct CtInfo ( bool hasCt , bool isNamedCt , bool isLast , string name )
245399 {
0 commit comments