The pipeline shape
TraefikBundleGenerator is an IIncrementalGenerator. Its entire Initialize method is structured as a three-stage pipeline. Each stage's output is value-equal so Roslyn can short-circuit downstream work whenever the input hasn't structurally changed. Here is the full method, slightly elided:
[Generator]
public sealed class TraefikBundleGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Stage 1: filter the AdditionalTexts to schemas we care about, then
// parse each one to a SchemaModel inside a Select so Roslyn can cache
// the parsed result. SchemaModel implements structural equality, so
// editing whitespace in a .json schema doesn't bust the downstream
// emit cache when the parsed shape is identical.
var parsedSchemas = context.AdditionalTextsProvider
.Where(static f => Path.GetFileName(f.Path).StartsWith("traefik-v") &&
f.Path.EndsWith(".json"))
.Select(static (file, ct) =>
{
var text = file.GetText(ct);
if (text is null) return null;
var filename = Path.GetFileName(file.Path);
var kind = TraefikSchemaReader.DetectKind(filename);
var version = TraefikSchemaReader.ExtractVersion(filename);
try
{
return TraefikSchemaReader.Parse(text.ToString(), version, kind);
}
catch
{
return null;
}
});
// Stage 2: collect + merge into the UnifiedSchema. Also value-equal,
// so this stage caches too.
var unifiedSchema = parsedSchemas.Collect().Select(static (schemas, ct) =>
{
var ordered = schemas
.Where(static s => s is not null)
.OrderBy(static s => s!.Version, StringComparer.Ordinal)
.ToList();
// … merge definitions, stamp SinceVersion on properties first
// seen in a later schema version (see Part 10).
return new UnifiedSchema { … };
});
// Stage 3: emit. Only re-runs when the UnifiedSchema's structural
// equality differs from the previously cached one.
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);
});
}
// … Emit() shown in Part 5
}[Generator]
public sealed class TraefikBundleGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Stage 1: filter the AdditionalTexts to schemas we care about, then
// parse each one to a SchemaModel inside a Select so Roslyn can cache
// the parsed result. SchemaModel implements structural equality, so
// editing whitespace in a .json schema doesn't bust the downstream
// emit cache when the parsed shape is identical.
var parsedSchemas = context.AdditionalTextsProvider
.Where(static f => Path.GetFileName(f.Path).StartsWith("traefik-v") &&
f.Path.EndsWith(".json"))
.Select(static (file, ct) =>
{
var text = file.GetText(ct);
if (text is null) return null;
var filename = Path.GetFileName(file.Path);
var kind = TraefikSchemaReader.DetectKind(filename);
var version = TraefikSchemaReader.ExtractVersion(filename);
try
{
return TraefikSchemaReader.Parse(text.ToString(), version, kind);
}
catch
{
return null;
}
});
// Stage 2: collect + merge into the UnifiedSchema. Also value-equal,
// so this stage caches too.
var unifiedSchema = parsedSchemas.Collect().Select(static (schemas, ct) =>
{
var ordered = schemas
.Where(static s => s is not null)
.OrderBy(static s => s!.Version, StringComparer.Ordinal)
.ToList();
// … merge definitions, stamp SinceVersion on properties first
// seen in a later schema version (see Part 10).
return new UnifiedSchema { … };
});
// Stage 3: emit. Only re-runs when the UnifiedSchema's structural
// equality differs from the previously cached one.
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);
});
}
// … Emit() shown in Part 5
}Three things to notice before we look at the parser:
- Each
Selectlambda isstatic. Roslyn can only cache stages whose lambdas don't capture closures. A non-staticlambda silently disables incrementality for that stage. - Stage 1 returns
nullon parse failure rather than throwing. A throw inside a generator stage poisons the whole compilation; returningnulllets Stage 2 simply skip the bad schema and report a TFK004 if everything fails. - Stage 3 reports TFK004 the moment it observes an empty
UnifiedSchema. This is the diagnostic that catches the "I forgot the<AdditionalFiles>line" mistake. The full diagnostic descriptor lives in the analyzer namespace and is shared with the IDE-time analyzer — see Part 7.
What TraefikSchemaReader.Parse produces
The parser turns a JSON document into a SchemaModel tree. The shape is deliberately uniform across both kinds of Traefik schema:
internal sealed class SchemaModel : IEquatable<SchemaModel>
{
public string Version { get; set; } = "";
public SchemaKind Kind { get; set; } // Static or Dynamic
public Dictionary<string, DefinitionModel> Definitions { get; set; } = new();
public List<PropertyModel> RootProperties { get; set; } = new();
// … Equals / GetHashCode use IrEquality helpers — see Part 4
}
internal enum SchemaKind { Static, Dynamic }internal sealed class SchemaModel : IEquatable<SchemaModel>
{
public string Version { get; set; } = "";
public SchemaKind Kind { get; set; } // Static or Dynamic
public Dictionary<string, DefinitionModel> Definitions { get; set; } = new();
public List<PropertyModel> RootProperties { get; set; } = new();
// … Equals / GetHashCode use IrEquality helpers — see Part 4
}
internal enum SchemaKind { Static, Dynamic }A static schema (traefik-v3-static.json) lands with Kind = Static and a flat list of RootProperties matching the top-level properties Traefik reads from traefik.yml at startup: EntryPoints, Providers, Api, Log, Metrics, Tracing, …
A dynamic schema (traefik-v3-file-provider.json, traefik-v3.1-file-provider.json) is also flattened into the same Kind = Dynamic shape. Inside the parser, the nested http, tcp, udp, tls sections become inline-class properties on the dynamic root, and each section's routers, services, middlewares collections are parsed into named Definitions. By the time Stage 1 finishes, both schema kinds look the same to downstream code.
The flat Definitions dictionary is where most of the interesting types live: TraefikHttpRouter, TraefikHttpService, TraefikBasicAuthMiddleware, TraefikRateLimitMiddleware, and so on — about 200 types per schema. Each one is a DefinitionModel:
internal sealed class DefinitionModel : IEquatable<DefinitionModel>
{
public string Name { get; set; } = "";
public string? Description { get; set; }
public List<PropertyModel> Properties { get; set; } = new();
public bool IsOneOfDiscriminated { get; set; }
public List<DiscriminatedBranch>? Branches { get; set; }
}internal sealed class DefinitionModel : IEquatable<DefinitionModel>
{
public string Name { get; set; } = "";
public string? Description { get; set; }
public List<PropertyModel> Properties { get; set; } = new();
public bool IsOneOfDiscriminated { get; set; }
public List<DiscriminatedBranch>? Branches { get; set; }
}The IsOneOfDiscriminated flag is set when the parser sees a JSON Schema oneOf array — that's how the 25-branch TraefikHttpMiddleware flat union (covered in Part 5) and its analyzer rule (covered in Part 7) get tagged.
What ends up cached
Stage 1's value type is SchemaModel?, Stage 2's is UnifiedSchema, Stage 3 has no value (just RegisterSourceOutput). Roslyn's incremental cache compares each stage's previous output to the new one using the value type's Equals. If Equals returns true, the downstream stage is skipped.
This is the entire reason Part 4 exists. Without IEquatable<T> on SchemaModel, DefinitionModel, PropertyModel, and friends, every keystroke that touches any file in the consuming project would re-run the emit stage — even when nothing schema-relevant changed. Reference equality on a fresh SchemaModel allocation always returns false. The next chapter shows how that's fixed.
The [TraefikBundle] marker (and why it isn't strictly required)
Bundle.Attributes exposes a marker attribute:
namespace FrenchExDev.Net.Traefik.Bundle.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)]
public sealed class TraefikBundleAttribute : Attribute { }namespace FrenchExDev.Net.Traefik.Bundle.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)]
public sealed class TraefikBundleAttribute : Attribute { }Earlier drafts of the generator used this attribute as the trigger — ForAttributeWithMetadataName("FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikBundle"). The current generator triggers off the AdditionalTextsProvider glob alone, because the only thing that needs to happen is "see traefik-v*.json, emit models". [TraefikBundle] is still shipped because the analyzer in Part 7 and downstream [TraefikDiscriminatedUnion] markers live in the same attribute assembly, and the consuming project always references it anyway.
← Part 2: Harvesting the Schema · Next: Part 4 — Value-Equal IR →