Skip to content

Commit b43a99d

Browse files
committed
fix: analyzer check for missing config
1 parent 569816e commit b43a99d

3 files changed

Lines changed: 174 additions & 59 deletions

File tree

examples/starter/KedroIris/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public class Program
2222
public static Task<int> Main(string[] args) =>
2323
FlowthruCli.RunStandaloneAsync(
2424
args,
25-
services => ConfigureServices(services, Directory.GetCurrentDirectory())
25+
services => ConfigureServices(services, basePath: Directory.GetCurrentDirectory())
2626
);
2727

2828
/// <summary>

src/core/Flowthru.SourceGenerators/AnalyzerReleases.Unshipped.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
### New Rules
55

6-
| Rule ID | Category | Severity | Notes |
7-
| ------- | --------------------- | -------- | ------------------------------------------------------- |
8-
| FT1001 | Flowthru.Schema | Error | FlowthruSchema type must be partial |
9-
| FT1002 | Flowthru.Schema | Warning | Conflicting manual schema interface |
10-
| FT2001 | Flowthru.Registration | Error | Pipeline requires catalog not registered via UseCatalog |
11-
| FT2002 | Flowthru.Registration | Warning | Catalog registered but not referenced by any pipeline |
6+
| Rule ID | Category | Severity | Notes |
7+
| ------- | --------------------- | -------- | --------------------------------------------------------------------------- |
8+
| FT1001 | Flowthru.Schema | Error | FlowthruSchema type must be partial |
9+
| FT1002 | Flowthru.Schema | Warning | Conflicting manual schema interface |
10+
| FT2001 | Flowthru.Registration | Error | Pipeline requires catalog not registered via UseCatalog |
11+
| FT2002 | Flowthru.Registration | Warning | Catalog registered but not referenced by any pipeline |
12+
| FT2003 | Flowthru.Registration | Warning | Concrete pipeline parameter resolved from DI; consider configurationSection |
13+
| FT2004 | Flowthru.Registration | Error | configurationSection specified but UseConfiguration() not called |

src/core/Flowthru.SourceGenerators/FlowthruRegistrationAnalyzer.cs

Lines changed: 165 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)