@@ -20,6 +20,8 @@ public class FlowthruRegistrationAnalyzer : DiagnosticAnalyzer
2020{
2121 public const string MissingCatalogId = "FT2001" ;
2222 public const string UnusedCatalogId = "FT2002" ;
23+ public const string UnboundConcreteParamId = "FT2003" ;
24+ public const string MissingUseConfigurationId = "FT2004" ;
2325
2426 private static readonly DiagnosticDescriptor MissingCatalogRule =
2527 new (
@@ -43,8 +45,35 @@ public class FlowthruRegistrationAnalyzer : DiagnosticAnalyzer
4345 description : "A catalog was registered but no pipeline references it. This may indicate dead configuration."
4446 ) ;
4547
48+ private static readonly DiagnosticDescriptor UnboundConcreteParamRule =
49+ new (
50+ UnboundConcreteParamId ,
51+ "Unbound concrete parameter" ,
52+ "Pipeline '{0}' has parameter '{1}' of type '{2}' that will be resolved from DI. If it is a configuration object, pass configurationSection to RegisterPipeline." ,
53+ "Flowthru.Registration" ,
54+ DiagnosticSeverity . Warning ,
55+ isEnabledByDefault : true ,
56+ description : "A concrete-class pipeline parameter that is not a catalog will be resolved from DI at pipeline-build time. If it is a configuration POCO, pass configurationSection to RegisterPipeline to bind it from appsettings instead."
57+ ) ;
58+
59+ private static readonly DiagnosticDescriptor MissingUseConfigurationRule =
60+ new (
61+ MissingUseConfigurationId ,
62+ "Missing UseConfiguration call" ,
63+ "Pipeline '{0}' specifies configurationSection '{1}' but UseConfiguration() has not been called" ,
64+ "Flowthru.Registration" ,
65+ DiagnosticSeverity . Error ,
66+ isEnabledByDefault : true ,
67+ description : "A RegisterPipeline call references a configurationSection, but UseConfiguration() was never called on the builder. The pipeline will throw at pre-flight time."
68+ ) ;
69+
4670 public override ImmutableArray < DiagnosticDescriptor > SupportedDiagnostics =>
47- ImmutableArray . Create ( MissingCatalogRule , UnusedCatalogRule ) ;
71+ ImmutableArray . Create (
72+ MissingCatalogRule ,
73+ UnusedCatalogRule ,
74+ UnboundConcreteParamRule ,
75+ MissingUseConfigurationRule
76+ ) ;
4877
4978 public override void Initialize ( AnalysisContext context )
5079 {
@@ -102,13 +131,18 @@ INamedTypeSymbol dataCatalogBaseType
102131 string ,
103132 IInvocationOperation
104133 > ( ) ;
105- // Collect pipeline registrations with their required catalog types
134+ // Collect pipeline registrations with their required catalogs and any ambiguous concrete params
106135 var pipelineRegistrations = new System . Collections . Generic . List < (
107136 string Label ,
108137 IInvocationOperation Invocation ,
109- System . Collections . Generic . List < ITypeSymbol > RequiredCatalogs
138+ System . Collections . Generic . List < ITypeSymbol > RequiredCatalogs ,
139+ System . Collections . Generic . List < ( ITypeSymbol Type , string ParamName ) > AmbiguousConcreteParams
110140 ) > ( ) ;
111141
142+ bool hasUseConfiguration = body . DescendantsAndSelf ( )
143+ . OfType < IInvocationOperation > ( )
144+ . Any ( c => c . TargetMethod . Name == "UseConfiguration" ) ;
145+
112146 foreach ( var descendant in body . DescendantsAndSelf ( ) )
113147 {
114148 if ( descendant is not IInvocationOperation call )
@@ -130,17 +164,20 @@ System.Collections.Generic.List<ITypeSymbol> RequiredCatalogs
130164 // ── RegisterPipeline ──
131165 if ( methodName == "RegisterPipeline" )
132166 {
133- var ( label , requiredCatalogs ) = ResolvePipelineRequirements ( call , dataCatalogBaseType ) ;
167+ var ( label , requiredCatalogs , ambiguousConcreteParams ) = ResolvePipelineRequirements (
168+ call ,
169+ dataCatalogBaseType
170+ ) ;
134171 if ( label != null )
135172 {
136- pipelineRegistrations . Add ( ( label , call , requiredCatalogs ) ) ;
173+ pipelineRegistrations . Add ( ( label , call , requiredCatalogs , ambiguousConcreteParams ) ) ;
137174 }
138175 }
139176 }
140177
141- // Cross-reference: FT1001 — pipeline requires catalog not registered
178+ // Cross-reference: FT2001 — pipeline requires catalog not registered
142179 var allReferencedCatalogs = new System . Collections . Generic . HashSet < string > ( ) ;
143- foreach ( var ( label , invocation , requiredCatalogs ) in pipelineRegistrations )
180+ foreach ( var ( label , invocation , requiredCatalogs , _ ) in pipelineRegistrations )
144181 {
145182 foreach ( var required in requiredCatalogs )
146183 {
@@ -160,7 +197,7 @@ System.Collections.Generic.List<ITypeSymbol> RequiredCatalogs
160197 }
161198 }
162199
163- // Cross-reference: FT1002 — catalog registered but never referenced
200+ // Cross-reference: FT2002 — catalog registered but never referenced
164201 foreach ( var kvp in registeredCatalogs )
165202 {
166203 if ( ! allReferencedCatalogs . Contains ( kvp . Key ) )
@@ -180,6 +217,54 @@ System.Collections.Generic.List<ITypeSymbol> RequiredCatalogs
180217 context . ReportDiagnostic ( diagnostic ) ;
181218 }
182219 }
220+
221+ // FT2003 — concrete non-catalog parameter not bound from configuration
222+ foreach ( var ( label , invocation , _, ambiguousConcreteParams ) in pipelineRegistrations )
223+ {
224+ foreach ( var ( paramType , paramName ) in ambiguousConcreteParams )
225+ {
226+ var diagnostic = Diagnostic . Create (
227+ UnboundConcreteParamRule ,
228+ invocation . Syntax . GetLocation ( ) ,
229+ label ,
230+ paramName ,
231+ paramType . ToDisplayString ( SymbolDisplayFormat . MinimallyQualifiedFormat )
232+ ) ;
233+ context . ReportDiagnostic ( diagnostic ) ;
234+ }
235+ }
236+
237+ // FT2004 — configurationSection supplied but UseConfiguration never called
238+ if ( ! hasUseConfiguration )
239+ {
240+ foreach ( var ( label , invocation , _, _) in pipelineRegistrations )
241+ {
242+ string ? sectionValue = null ;
243+ foreach ( var arg in invocation . Arguments )
244+ {
245+ if (
246+ arg . Parameter ? . Name == "configurationSection"
247+ && arg . Value . ConstantValue . HasValue
248+ && arg . Value . ConstantValue . Value is string s
249+ )
250+ {
251+ sectionValue = s ;
252+ break ;
253+ }
254+ }
255+
256+ if ( sectionValue != null )
257+ {
258+ var diagnostic = Diagnostic . Create (
259+ MissingUseConfigurationRule ,
260+ invocation . Syntax . GetLocation ( ) ,
261+ label ,
262+ sectionValue
263+ ) ;
264+ context . ReportDiagnostic ( diagnostic ) ;
265+ }
266+ }
267+ }
183268 }
184269
185270 /// <summary>
@@ -223,18 +308,31 @@ INamedTypeSymbol dataCatalogBaseType
223308 }
224309
225310 /// <summary>
226- /// Extracts the pipeline label and required catalog types from a RegisterPipeline call.
227- /// Resolves the Delegate argument to its method signature and inspects parameter types.
311+ /// Extracts the pipeline label, required catalog types, and unbound concrete parameters
312+ /// from a RegisterPipeline call.
313+ /// <para>
314+ /// Parameters are classified the same way the runtime resolver does:
315+ /// <list type="bullet">
316+ /// <item>Extends <c>DataCatalogBase</c> → required catalog (FT2001 if missing UseCatalog)</item>
317+ /// <item>Interface → DI-resolved service (no warning — extension territory)</item>
318+ /// <item>Concrete class, not covered by configurationSection → ambiguous (FT2003)</item>
319+ /// </list>
320+ /// </para>
228321 /// </summary>
229322 private static (
230323 string ? Label ,
231- System . Collections . Generic . List < ITypeSymbol > RequiredCatalogs
324+ System . Collections . Generic . List < ITypeSymbol > RequiredCatalogs ,
325+ System . Collections . Generic . List < ( ITypeSymbol Type , string ParamName ) > AmbiguousConcreteParams
232326 ) ResolvePipelineRequirements ( IInvocationOperation call , INamedTypeSymbol dataCatalogBaseType )
233327 {
234328 string ? label = null ;
235329 var requiredCatalogs = new System . Collections . Generic . List < ITypeSymbol > ( ) ;
330+ var ambiguousConcreteParams = new System . Collections . Generic . List < (
331+ ITypeSymbol Type ,
332+ string ParamName
333+ ) > ( ) ;
236334
237- // Extract label from first string argument
335+ // Extract label
238336 foreach ( var arg in call . Arguments )
239337 {
240338 if ( arg . Parameter ? . Name == "label" && arg . Value . ConstantValue . HasValue )
@@ -244,68 +342,83 @@ System.Collections.Generic.List<ITypeSymbol> RequiredCatalogs
244342 }
245343 }
246344
247- // Extract delegate parameter — find the 'pipeline' argument
345+ // Detect whether configurationSection was supplied with a non-null string value.
346+ // The runtime binds the FIRST concrete non-catalog non-interface param from config;
347+ // all others fall through to DI.
348+ bool hasConfigSection = false ;
349+ foreach ( var arg in call . Arguments )
350+ {
351+ if (
352+ arg . Parameter ? . Name == "configurationSection"
353+ && arg . Value . ConstantValue . HasValue
354+ && arg . Value . ConstantValue . Value is string
355+ )
356+ {
357+ hasConfigSection = true ;
358+ break ;
359+ }
360+ }
361+
362+ // Resolve pipeline parameters via delegate signature or method group.
363+ System . Collections . Generic . IEnumerable < IParameterSymbol > ? pipelineParams = null ;
248364 foreach ( var arg in call . Arguments )
249365 {
250366 if ( arg . Parameter ? . Name != "pipeline" )
251367 continue ;
252368
369+ // Path 1: lambda / typed Func — read from the delegate's Invoke method
253370 var delegateType = ResolveMethodSignatureFromArgument ( arg . Value ) ;
254- if ( delegateType == null )
255- continue ;
256-
257- // The delegate's invoke method parameters are the pipeline's dependencies
258- var invokeMethod = delegateType . DelegateInvokeMethod ;
259- if ( invokeMethod == null )
260- continue ;
261-
262- foreach ( var param in invokeMethod . Parameters )
371+ if ( delegateType ? . DelegateInvokeMethod != null )
263372 {
264- if ( InheritsFrom ( param . Type , dataCatalogBaseType ) )
265- {
266- requiredCatalogs . Add ( param . Type ) ;
267- }
373+ pipelineParams = delegateType . DelegateInvokeMethod . Parameters ;
374+ break ;
268375 }
376+
377+ // Path 2: method group — unwrap any conversion wrapper and read Method.Parameters
378+ IOperation value = arg . Value ;
379+ while ( value is IConversionOperation conv )
380+ value = conv . Operand ;
381+ if ( value is IMethodReferenceOperation methodRef )
382+ pipelineParams = methodRef . Method . Parameters ;
383+
269384 break ;
270385 }
271386
272- // Also try resolving from method group directly
273- if ( requiredCatalogs . Count == 0 )
387+ if ( pipelineParams != null )
274388 {
275- foreach ( var arg in call . Arguments )
276- {
277- if ( arg . Parameter ? . Name != "pipeline" )
278- continue ;
389+ // The runtime consumes configurationSection on the first concrete non-catalog
390+ // non-interface param it encounters (left to right). Track that slot.
391+ bool configSectionConsumed = false ;
279392
280- // For method group conversions, walk to the referenced method
281- if ( arg . Value is IMethodReferenceOperation methodRef )
393+ foreach ( var param in pipelineParams )
394+ {
395+ if ( InheritsFrom ( param . Type , dataCatalogBaseType ) )
282396 {
283- foreach ( var param in methodRef . Method . Parameters )
284- {
285- if ( InheritsFrom ( param . Type , dataCatalogBaseType ) )
286- {
287- requiredCatalogs . Add ( param . Type ) ;
288- }
289- }
397+ // Catalog — must be registered via UseCatalog.
398+ requiredCatalogs . Add ( param . Type ) ;
399+ }
400+ else if ( param . Type . TypeKind == TypeKind . Interface )
401+ {
402+ // Interface — DI-resolved service. Core has no visibility into what registers
403+ // it; extensions own that contract. No diagnostic.
290404 }
291- else if (
292- arg . Value is IConversionOperation conversion
293- && conversion . Operand is IMethodReferenceOperation innerRef
294- )
405+ else
295406 {
296- foreach ( var param in innerRef . Method . Parameters )
407+ // Concrete non-catalog class — could be a config POCO or an explicitly
408+ // registered DI type. If configurationSection covers this slot, it's fine.
409+ if ( hasConfigSection && ! configSectionConsumed )
410+ {
411+ configSectionConsumed = true ;
412+ }
413+ else
297414 {
298- if ( InheritsFrom ( param . Type , dataCatalogBaseType ) )
299- {
300- requiredCatalogs . Add ( param . Type ) ;
301- }
415+ ambiguousConcreteParams . Add ( ( param . Type , param . Name ) ) ;
302416 }
303417 }
304- break ;
305418 }
306419 }
307420
308- return ( label , requiredCatalogs ) ;
421+ return ( label , requiredCatalogs , ambiguousConcreteParams ) ;
309422 }
310423
311424 private static INamedTypeSymbol ? ResolveMethodSignatureFromArgument ( IOperation value )
0 commit comments