Two diagnostics, one rule catalog
The analyzer side of the bundle is intentionally small: one DiagnosticAnalyzer and one DiagnosticDescriptor registry, both in the same Bundle.SourceGenerator/Analyzers/ folder. Centralizing the descriptors avoids the classic drift between the analyzer assembly and the AnalyzerReleases.Shipped.md / AnalyzerReleases.Unshipped.md manifests Roslyn requires for shippable analyzers.
namespace FrenchExDev.Net.Traefik.Bundle.SourceGenerator.Analyzers;
internal static class TraefikDiagnostics
{
private const string Category = "TraefikBundle";
public static readonly DiagnosticDescriptor MultipleBranchesSet = new(
id: "TFK001",
title: "Discriminated union has more than one branch set",
messageFormat: "'{0}' is a Traefik discriminated union — exactly one branch must be set, but the initializer assigns {1}",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Traefik flat unions (e.g. http middleware, http service) silently reject configurations with more than one branch populated.");
// TFK002 reserved for "dangling router → service reference" — see PLAN.md.
// Implementing it well requires walking the assembled config graph; today's
// shape doesn't make this cheap to detect across files, so the rule is
// documented but not yet active.
// TFK003 ("use of deprecated Traefik property") is intentionally subsumed
// by the standard CS0618 warning. The model emitter stamps [Obsolete] on
// every property where the schema sets `deprecated: true`, so the C#
// compiler reports it natively without a custom analyzer.
public static readonly DiagnosticDescriptor NoSchemasFound = new(
id: "TFK004",
title: "No Traefik schemas wired as AdditionalFiles",
messageFormat: "The Traefik bundle source generator did not find any 'traefik-v3-*.json' AdditionalFiles. No models will be generated.",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Add the embedded schemas to your csproj as <AdditionalFiles> for the generator to produce models.");
}namespace FrenchExDev.Net.Traefik.Bundle.SourceGenerator.Analyzers;
internal static class TraefikDiagnostics
{
private const string Category = "TraefikBundle";
public static readonly DiagnosticDescriptor MultipleBranchesSet = new(
id: "TFK001",
title: "Discriminated union has more than one branch set",
messageFormat: "'{0}' is a Traefik discriminated union — exactly one branch must be set, but the initializer assigns {1}",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Traefik flat unions (e.g. http middleware, http service) silently reject configurations with more than one branch populated.");
// TFK002 reserved for "dangling router → service reference" — see PLAN.md.
// Implementing it well requires walking the assembled config graph; today's
// shape doesn't make this cheap to detect across files, so the rule is
// documented but not yet active.
// TFK003 ("use of deprecated Traefik property") is intentionally subsumed
// by the standard CS0618 warning. The model emitter stamps [Obsolete] on
// every property where the schema sets `deprecated: true`, so the C#
// compiler reports it natively without a custom analyzer.
public static readonly DiagnosticDescriptor NoSchemasFound = new(
id: "TFK004",
title: "No Traefik schemas wired as AdditionalFiles",
messageFormat: "The Traefik bundle source generator did not find any 'traefik-v3-*.json' AdditionalFiles. No models will be generated.",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Add the embedded schemas to your csproj as <AdditionalFiles> for the generator to produce models.");
}Two things worth flagging in the comments above the descriptors:
- TFK002 (dangling router → service reference) is a deliberately reserved ID, not a forgotten one. Detecting it cheaply across files would require walking the assembled config graph, which the current pipeline doesn't materialize at analysis time. Reserving the ID prevents future renumbering.
- TFK003 is deliberately not implemented, because the existing C# compiler diagnostic
CS0618(obsolete member) already covers the only case it would catch. The model emitter stamps[Obsolete("Deprecated.")]on every property where the JSON schema setsdeprecated: true(see Part 5), and the compiler does the rest. Reusing built-in machinery is always better than writing a custom analyzer for the same job.
TFK001: the analyzer
DiscriminatedUnionAnalyzer keys off the [TraefikDiscriminatedUnion] attribute the model emitter stamps on every flat-union class. The analyzer doesn't enumerate Traefik types; it asks the semantic model "does this object's type carry that one specific attribute?" and walks the initializer if it does:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DiscriminatedUnionAnalyzer : DiagnosticAnalyzer
{
private const string AttributeFullName =
"FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnionAttribute";
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(TraefikDiagnostics.MultipleBranchesSet);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression);
context.RegisterSyntaxNodeAction(AnalyzeImplicitObjectCreation, SyntaxKind.ImplicitObjectCreationExpression);
}
private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext ctx)
{
var node = (ObjectCreationExpressionSyntax)ctx.Node;
AnalyzeInitializer(ctx, node.Initializer, node);
}
private static void AnalyzeImplicitObjectCreation(SyntaxNodeAnalysisContext ctx)
{
var node = (ImplicitObjectCreationExpressionSyntax)ctx.Node;
AnalyzeInitializer(ctx, node.Initializer, node);
}
private static void AnalyzeInitializer(
SyntaxNodeAnalysisContext ctx,
InitializerExpressionSyntax? initializer,
ExpressionSyntax creationNode)
{
if (initializer is null) return;
if (initializer.Expressions.Count < 2) return;
var typeInfo = ctx.SemanticModel.GetTypeInfo(creationNode, ctx.CancellationToken);
var type = typeInfo.Type;
if (type is null) return;
if (!HasDiscriminatedUnionAttribute(type)) return;
// Count how many branch property assignments are *not* explicitly null.
var setBranches = 0;
var firstSetName = (string?)null;
var secondSetSpan = (Location?)null;
foreach (var expr in initializer.Expressions)
{
if (expr is not AssignmentExpressionSyntax assignment) continue;
if (assignment.Left is not IdentifierNameSyntax id) continue;
// null literal RHS doesn't count as a branch being set.
if (assignment.Right is LiteralExpressionSyntax lit &&
lit.IsKind(SyntaxKind.NullLiteralExpression))
{
continue;
}
setBranches++;
if (setBranches == 1)
{
firstSetName = id.Identifier.Text;
}
else if (setBranches == 2)
{
secondSetSpan = assignment.GetLocation();
}
}
if (setBranches > 1 && secondSetSpan is not null)
{
ctx.ReportDiagnostic(Diagnostic.Create(
TraefikDiagnostics.MultipleBranchesSet,
secondSetSpan,
type.Name,
setBranches));
}
}
private static bool HasDiscriminatedUnionAttribute(ITypeSymbol type)
{
foreach (var attr in type.GetAttributes())
{
var attrClass = attr.AttributeClass;
if (attrClass is null) continue;
if (attrClass.ToDisplayString() == AttributeFullName) return true;
}
return false;
}
}[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DiscriminatedUnionAnalyzer : DiagnosticAnalyzer
{
private const string AttributeFullName =
"FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnionAttribute";
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(TraefikDiagnostics.MultipleBranchesSet);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression);
context.RegisterSyntaxNodeAction(AnalyzeImplicitObjectCreation, SyntaxKind.ImplicitObjectCreationExpression);
}
private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext ctx)
{
var node = (ObjectCreationExpressionSyntax)ctx.Node;
AnalyzeInitializer(ctx, node.Initializer, node);
}
private static void AnalyzeImplicitObjectCreation(SyntaxNodeAnalysisContext ctx)
{
var node = (ImplicitObjectCreationExpressionSyntax)ctx.Node;
AnalyzeInitializer(ctx, node.Initializer, node);
}
private static void AnalyzeInitializer(
SyntaxNodeAnalysisContext ctx,
InitializerExpressionSyntax? initializer,
ExpressionSyntax creationNode)
{
if (initializer is null) return;
if (initializer.Expressions.Count < 2) return;
var typeInfo = ctx.SemanticModel.GetTypeInfo(creationNode, ctx.CancellationToken);
var type = typeInfo.Type;
if (type is null) return;
if (!HasDiscriminatedUnionAttribute(type)) return;
// Count how many branch property assignments are *not* explicitly null.
var setBranches = 0;
var firstSetName = (string?)null;
var secondSetSpan = (Location?)null;
foreach (var expr in initializer.Expressions)
{
if (expr is not AssignmentExpressionSyntax assignment) continue;
if (assignment.Left is not IdentifierNameSyntax id) continue;
// null literal RHS doesn't count as a branch being set.
if (assignment.Right is LiteralExpressionSyntax lit &&
lit.IsKind(SyntaxKind.NullLiteralExpression))
{
continue;
}
setBranches++;
if (setBranches == 1)
{
firstSetName = id.Identifier.Text;
}
else if (setBranches == 2)
{
secondSetSpan = assignment.GetLocation();
}
}
if (setBranches > 1 && secondSetSpan is not null)
{
ctx.ReportDiagnostic(Diagnostic.Create(
TraefikDiagnostics.MultipleBranchesSet,
secondSetSpan,
type.Name,
setBranches));
}
}
private static bool HasDiscriminatedUnionAttribute(ITypeSymbol type)
{
foreach (var attr in type.GetAttributes())
{
var attrClass = attr.AttributeClass;
if (attrClass is null) continue;
if (attrClass.ToDisplayString() == AttributeFullName) return true;
}
return false;
}
}A few decisions worth highlighting:
ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None)— the analyzer skips generated code by default, which is correct: the.g.csfiles contain the model definition itself, not user code that misuses it.- Both
ObjectCreationExpressionandImplicitObjectCreationExpressionare registered, because C# 9 lets you writeTraefikHttpMiddleware m = new() { … }and that produces a different syntax kind. Forgetting either one would make the rule silently miss target syntax. - Explicit
nullassignments don't count as a branch being set. WritingBasicAuth = nullis a no-op and shouldn't trigger TFK001 — defensive code that explicitly nulls all-but-one branch is legitimate. - The diagnostic location is the second offending assignment, not the whole initializer. That puts the squiggle exactly on the line you typed, not on the whole
new TraefikHttpMiddleware { … }block. - Severity is
Warning, notError. The reasoning: there's a real chance someone is writing an initializer in the middle of refactoring and will fix it before checkin. Errors block that. A warning shows up immediately, but doesn't make CI red until you also enable<TreatWarningsAsErrors>(which the bundle's own quality gate does).
What the user sees
var middleware = new TraefikHttpMiddleware
{
AddPrefix = new() { Prefix = "/v1" },
BasicAuth = new() { Users = new() { "admin:..." } },
// ^^^^^^^^^^^^^^^^
// TFK001: 'TraefikHttpMiddleware' is a Traefik discriminated
// union — exactly one branch must be set, but the initializer
// assigns 2
};var middleware = new TraefikHttpMiddleware
{
AddPrefix = new() { Prefix = "/v1" },
BasicAuth = new() { Users = new() { "admin:..." } },
// ^^^^^^^^^^^^^^^^
// TFK001: 'TraefikHttpMiddleware' is a Traefik discriminated
// union — exactly one branch must be set, but the initializer
// assigns 2
};That squiggle appears in the IDE without compiling, without running tests, without launching Traefik. The same shape inside a builder call (new TraefikHttpMiddlewareBuilder().WithAddPrefix(...).WithBasicAuth(...)) would not be caught here — the analyzer only looks at object initializers — but the runtime __setCount == 1 epilogue from Part 6 catches it instead. The two checks together cover essentially every way to misconstruct a flat union in C#.
TFK004: reported by the generator, not the analyzer
TFK004 is interesting because it lives on a different schedule: it can only be detected when the generator runs (the analyzer can't see the AdditionalFiles collection). So the generator itself reports it, using the same DiagnosticDescriptor from the central registry:
// from TraefikBundleGenerator.cs, inside RegisterSourceOutput:
context.RegisterSourceOutput(unifiedSchema, static (ctx, unified) =>
{
if (unified.Definitions.Count == 0 && unified.RootProperties.Count == 0)
{
// TFK004: no schemas were wired in. The consumer's csproj is
// missing <AdditionalFiles Include="schemas\traefik-v3-*.json" />.
ctx.ReportDiagnostic(Diagnostic.Create(
Analyzers.TraefikDiagnostics.NoSchemasFound,
Location.None));
return;
}
var ns = "FrenchExDev.Net.Traefik.Bundle";
Emit(ctx, ns, unified);
});// from TraefikBundleGenerator.cs, inside RegisterSourceOutput:
context.RegisterSourceOutput(unifiedSchema, static (ctx, unified) =>
{
if (unified.Definitions.Count == 0 && unified.RootProperties.Count == 0)
{
// TFK004: no schemas were wired in. The consumer's csproj is
// missing <AdditionalFiles Include="schemas\traefik-v3-*.json" />.
ctx.ReportDiagnostic(Diagnostic.Create(
Analyzers.TraefikDiagnostics.NoSchemasFound,
Location.None));
return;
}
var ns = "FrenchExDev.Net.Traefik.Bundle";
Emit(ctx, ns, unified);
});Location.None because the diagnostic isn't attached to a line of source code — it's about the project configuration as a whole. The user sees a single warning at the top of the build output saying "no traefik-v3-*.json AdditionalFiles found". That single line is the difference between an hour of debugging "why does my code reference TraefikStaticConfig not exist?" and a one-line .csproj fix.
The detail that makes this work is that TraefikDiagnostics is internal to Bundle.SourceGenerator but visible to both the generator and the analyzer because both live in the same assembly. There is exactly one place to add a new rule ID, exactly one place to bump severity, exactly one source for the AnalyzerReleases.*.md manifests.
The full layered defense
Three checks, three timing layers, one underlying invariant ("a flat discriminated union has exactly one branch set, and the project must wire its schemas"):
| Check | Layer | When it fires | What it catches |
|---|---|---|---|
TFK004 (TraefikBundleGenerator.cs:140) |
Build-time | Generator stage 3 finds an empty UnifiedSchema |
Missing <AdditionalFiles> line in consumer .csproj |
TFK001 (DiscriminatedUnionAnalyzer.cs) |
Edit-time (IDE) | User types an object initializer with ≥2 branch assignments on a [TraefikDiscriminatedUnion] type |
new TraefikHttpMiddleware { AddPrefix = …, BasicAuth = … } |
__setCount == 1 epilogue (Part 6) |
Runtime | BuildAsync is called on a builder with ≠ 1 branch populated |
Same shape, but constructed via …Builder().WithAddPrefix(…).WithBasicAuth(…) |
There is no CodeFixProvider yet — see the honest "next steps" list in Part 10. A code fix that proposed "remove all but one branch" is plausible, but it's a destructive auto-fix and the right semantic depends on the user's intent. For now the warning is enough; the user picks which branch to keep.
← Part 6: Fluent Builders · Next: Part 8 — YAML & JSON Round-Tripping →