The trap
A Roslyn IIncrementalGenerator caches each pipeline stage's output. When the next compilation runs, Roslyn re-executes the stage with the new input, then compares the new output to the cached one. If the comparison returns true, every downstream stage is skipped.
The comparison uses EqualityComparer<T>.Default — i.e. T.Equals. For records and primitives that's structural. For plain mutable classes, that's reference equality. So the moment you put a class Foo { … } IR node into a Select or Collect stage, every keystroke in the user's project re-allocates a new Foo, and reference equality returns false. The cache is dead. You wrote an "incremental" generator that re-runs everything on every keystroke, and you don't notice until you profile.
This is the silent, default-on failure mode of incremental generators. Nothing crashes. The IDE just feels a bit sluggish.
How it manifests in this project
TraefikSchemaReader.Parse returns a SchemaModel. SchemaModel holds a Dictionary<string, DefinitionModel> and a List<PropertyModel>. DefinitionModel holds a list of PropertyModel. PropertyModel may recursively hold a nested PropertyModel? Items plus inline-class lists.
The IR has to stay as plain mutable classes — the parser builds them imperatively via property setters and patches them up as it walks the JSON document. Records would force a constructor call for every node and the parser would become unreadable. So the trap is unavoidable unless every IR class implements IEquatable<T> by hand.
The fix: IrEquality
The whole answer is one helper file plus IEquatable<T> boilerplate on every IR node. Here is the helper, in full:
namespace FrenchExDev.Net.Traefik.Bundle.SourceGenerator;
/// <summary>
/// Structural equality helpers for the IR types so that the incremental
/// source-generator pipeline can cache parsed schema models. Without this,
/// every keystroke that touches an AdditionalText would force the emit
/// stage to re-run even when the parsed shape is identical.
/// </summary>
internal static class IrEquality
{
public static bool ListEqual<T>(List<T>? a, List<T>? b)
{
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null) return false;
if (a.Count != b.Count) return false;
var cmp = EqualityComparer<T>.Default;
for (var i = 0; i < a.Count; i++)
if (!cmp.Equals(a[i], b[i])) return false;
return true;
}
public static bool DictEqual<TKey, TValue>(
Dictionary<TKey, TValue>? a, Dictionary<TKey, TValue>? b)
{
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null) return false;
if (a.Count != b.Count) return false;
var cmp = EqualityComparer<TValue>.Default;
foreach (var kvp in a)
{
if (!b.TryGetValue(kvp.Key, out var other)) return false;
if (!cmp.Equals(kvp.Value, other)) return false;
}
return true;
}
public static int ListHash<T>(List<T>? items)
{
if (items is null) return 0;
var hash = 17;
var cmp = EqualityComparer<T>.Default;
foreach (var item in items)
hash = unchecked(hash * 31 + (item is null ? 0 : cmp.GetHashCode(item!)));
return hash;
}
public static int DictHash<TKey, TValue>(Dictionary<TKey, TValue>? dict)
{
if (dict is null) return 0;
// Order-independent hash: sum of per-entry hashes.
var hash = 0;
var keyCmp = EqualityComparer<TKey>.Default;
var valCmp = EqualityComparer<TValue>.Default;
foreach (var kvp in dict)
{
var entryHash = 17;
entryHash = unchecked(entryHash * 31 + (kvp.Key is null ? 0 : keyCmp.GetHashCode(kvp.Key!)));
entryHash = unchecked(entryHash * 31 + (kvp.Value is null ? 0 : valCmp.GetHashCode(kvp.Value!)));
hash = unchecked(hash + entryHash);
}
return hash;
}
public static int Combine(params int[] hashes)
{
var hash = 17;
foreach (var h in hashes)
hash = unchecked(hash * 31 + h);
return hash;
}
public static int HashOfString(string? value)
=> value is null ? 0 : value.GetHashCode();
}namespace FrenchExDev.Net.Traefik.Bundle.SourceGenerator;
/// <summary>
/// Structural equality helpers for the IR types so that the incremental
/// source-generator pipeline can cache parsed schema models. Without this,
/// every keystroke that touches an AdditionalText would force the emit
/// stage to re-run even when the parsed shape is identical.
/// </summary>
internal static class IrEquality
{
public static bool ListEqual<T>(List<T>? a, List<T>? b)
{
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null) return false;
if (a.Count != b.Count) return false;
var cmp = EqualityComparer<T>.Default;
for (var i = 0; i < a.Count; i++)
if (!cmp.Equals(a[i], b[i])) return false;
return true;
}
public static bool DictEqual<TKey, TValue>(
Dictionary<TKey, TValue>? a, Dictionary<TKey, TValue>? b)
{
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null) return false;
if (a.Count != b.Count) return false;
var cmp = EqualityComparer<TValue>.Default;
foreach (var kvp in a)
{
if (!b.TryGetValue(kvp.Key, out var other)) return false;
if (!cmp.Equals(kvp.Value, other)) return false;
}
return true;
}
public static int ListHash<T>(List<T>? items)
{
if (items is null) return 0;
var hash = 17;
var cmp = EqualityComparer<T>.Default;
foreach (var item in items)
hash = unchecked(hash * 31 + (item is null ? 0 : cmp.GetHashCode(item!)));
return hash;
}
public static int DictHash<TKey, TValue>(Dictionary<TKey, TValue>? dict)
{
if (dict is null) return 0;
// Order-independent hash: sum of per-entry hashes.
var hash = 0;
var keyCmp = EqualityComparer<TKey>.Default;
var valCmp = EqualityComparer<TValue>.Default;
foreach (var kvp in dict)
{
var entryHash = 17;
entryHash = unchecked(entryHash * 31 + (kvp.Key is null ? 0 : keyCmp.GetHashCode(kvp.Key!)));
entryHash = unchecked(entryHash * 31 + (kvp.Value is null ? 0 : valCmp.GetHashCode(kvp.Value!)));
hash = unchecked(hash + entryHash);
}
return hash;
}
public static int Combine(params int[] hashes)
{
var hash = 17;
foreach (var h in hashes)
hash = unchecked(hash * 31 + h);
return hash;
}
public static int HashOfString(string? value)
=> value is null ? 0 : value.GetHashCode();
}Three subtle things:
DictHashis order-independent (sum, not multiply-and-accumulate). Roslyn doesn't guarantee dictionary iteration order, so an order-sensitive hash would produce different values for structurally equal dicts.ListHashis order-sensitive. Schema property order matters because the emitter writes properties in document order, and reordering would produce a different.g.cs.Combineuses prime-multiply. Standard, but worth noting:uncheckedis required because the hash is allowed to wrap around.
Wiring it into the IR
Every IR class implements IEquatable<T> by delegating to the helper. Here is PropertyModel, which has the most fields and the deepest nesting:
internal sealed class PropertyModel : IEquatable<PropertyModel>
{
public string JsonName { get; set; } = "";
public string CSharpName { get; set; } = "";
public string? Description { get; set; }
public bool IsDeprecated { get; set; }
public PropertyType Type { get; set; } = PropertyType.String;
public string? Ref { get; set; }
public PropertyModel? Items { get; set; } // recursive!
public List<string>? EnumValues { get; set; }
public bool IsRequired { get; set; }
public bool IsNullable { get; set; }
public string? InlineClassName { get; set; }
public List<PropertyModel>? InlineObjectProperties { get; set; } // recursive!
public string? DictOfRefTarget { get; set; }
public string? PatternPropsInlineClassName { get; set; }
public List<PropertyModel>? PatternPropsInlineProperties { get; set; }
public bool Equals(PropertyModel? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return JsonName == other.JsonName
&& CSharpName == other.CSharpName
&& Description == other.Description
&& IsDeprecated == other.IsDeprecated
&& Type == other.Type
&& Ref == other.Ref
&& IsRequired == other.IsRequired
&& IsNullable == other.IsNullable
&& InlineClassName == other.InlineClassName
&& DictOfRefTarget == other.DictOfRefTarget
&& PatternPropsInlineClassName == other.PatternPropsInlineClassName
&& Equals(Items, other.Items)
&& IrEquality.ListEqual(EnumValues, other.EnumValues)
&& IrEquality.ListEqual(InlineObjectProperties, other.InlineObjectProperties)
&& IrEquality.ListEqual(PatternPropsInlineProperties, other.PatternPropsInlineProperties);
}
public override bool Equals(object? obj) => Equals(obj as PropertyModel);
public override int GetHashCode() => IrEquality.Combine(
IrEquality.HashOfString(JsonName),
IrEquality.HashOfString(CSharpName),
IrEquality.HashOfString(Description),
IsDeprecated ? 1 : 0,
(int)Type,
IrEquality.HashOfString(Ref),
IsRequired ? 1 : 0,
IsNullable ? 1 : 0,
IrEquality.HashOfString(InlineClassName),
IrEquality.HashOfString(DictOfRefTarget),
IrEquality.HashOfString(PatternPropsInlineClassName),
Items?.GetHashCode() ?? 0,
IrEquality.ListHash(EnumValues),
IrEquality.ListHash(InlineObjectProperties),
IrEquality.ListHash(PatternPropsInlineProperties));
}internal sealed class PropertyModel : IEquatable<PropertyModel>
{
public string JsonName { get; set; } = "";
public string CSharpName { get; set; } = "";
public string? Description { get; set; }
public bool IsDeprecated { get; set; }
public PropertyType Type { get; set; } = PropertyType.String;
public string? Ref { get; set; }
public PropertyModel? Items { get; set; } // recursive!
public List<string>? EnumValues { get; set; }
public bool IsRequired { get; set; }
public bool IsNullable { get; set; }
public string? InlineClassName { get; set; }
public List<PropertyModel>? InlineObjectProperties { get; set; } // recursive!
public string? DictOfRefTarget { get; set; }
public string? PatternPropsInlineClassName { get; set; }
public List<PropertyModel>? PatternPropsInlineProperties { get; set; }
public bool Equals(PropertyModel? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return JsonName == other.JsonName
&& CSharpName == other.CSharpName
&& Description == other.Description
&& IsDeprecated == other.IsDeprecated
&& Type == other.Type
&& Ref == other.Ref
&& IsRequired == other.IsRequired
&& IsNullable == other.IsNullable
&& InlineClassName == other.InlineClassName
&& DictOfRefTarget == other.DictOfRefTarget
&& PatternPropsInlineClassName == other.PatternPropsInlineClassName
&& Equals(Items, other.Items)
&& IrEquality.ListEqual(EnumValues, other.EnumValues)
&& IrEquality.ListEqual(InlineObjectProperties, other.InlineObjectProperties)
&& IrEquality.ListEqual(PatternPropsInlineProperties, other.PatternPropsInlineProperties);
}
public override bool Equals(object? obj) => Equals(obj as PropertyModel);
public override int GetHashCode() => IrEquality.Combine(
IrEquality.HashOfString(JsonName),
IrEquality.HashOfString(CSharpName),
IrEquality.HashOfString(Description),
IsDeprecated ? 1 : 0,
(int)Type,
IrEquality.HashOfString(Ref),
IsRequired ? 1 : 0,
IsNullable ? 1 : 0,
IrEquality.HashOfString(InlineClassName),
IrEquality.HashOfString(DictOfRefTarget),
IrEquality.HashOfString(PatternPropsInlineClassName),
Items?.GetHashCode() ?? 0,
IrEquality.ListHash(EnumValues),
IrEquality.ListHash(InlineObjectProperties),
IrEquality.ListHash(PatternPropsInlineProperties));
}The two recursive cases (Items for arrays-of-X and InlineObjectProperties for inline objects) are handled by calling Equals/GetHashCode on the nested PropertyModel, which itself uses the same helpers — the recursion bottoms out naturally at leaf nodes whose lists are null or empty.
SchemaModel, DefinitionModel, DiscriminatedBranch, UnifiedSchema, UnifiedDefinition, and UnifiedProperty all follow the same pattern. There is no shortcut here — every field that contributes to the emitted output must participate in Equals, otherwise two structurally-different schemas can be reported as equal and the cache will return stale .g.cs.
Pinning the contract: IrEqualityTests
IrEquality is invisible to consumers — it lives in the source generator assembly and never crosses the IDE boundary. The only way to defend it from accidental regressions is unit tests on the IR types themselves. Bundle.SourceGenerator.Tests/IrEqualityTests.cs keeps it honest:
public sealed class IrEqualityTests
{
[Fact]
public void PropertyModel_StructurallyEqual_WhenAllFieldsMatch()
{
var a = Build();
var b = Build();
a.ShouldBe(b);
a.GetHashCode().ShouldBe(b.GetHashCode());
static PropertyModel Build() => new()
{
JsonName = "entryPoints",
CSharpName = "EntryPoints",
Description = "EntryPoints holds the EntryPoints configuration.",
Type = PropertyType.DictOfRef,
DictOfRefTarget = "entryPoint",
};
}
[Fact]
public void PropertyModel_NotEqual_WhenDescriptionDiffers()
{
var a = new PropertyModel { JsonName = "x", CSharpName = "X", Description = "old" };
var b = new PropertyModel { JsonName = "x", CSharpName = "X", Description = "new" };
a.ShouldNotBe(b);
}
[Fact]
public void PropertyModel_StructurallyEqual_OnNestedItems()
{
PropertyModel Build() => new()
{
JsonName = "list",
CSharpName = "List",
Type = PropertyType.Array,
Items = new PropertyModel
{
JsonName = "item",
CSharpName = "Item",
Type = PropertyType.Ref,
Ref = "router",
},
};
Build().ShouldBe(Build());
}
}public sealed class IrEqualityTests
{
[Fact]
public void PropertyModel_StructurallyEqual_WhenAllFieldsMatch()
{
var a = Build();
var b = Build();
a.ShouldBe(b);
a.GetHashCode().ShouldBe(b.GetHashCode());
static PropertyModel Build() => new()
{
JsonName = "entryPoints",
CSharpName = "EntryPoints",
Description = "EntryPoints holds the EntryPoints configuration.",
Type = PropertyType.DictOfRef,
DictOfRefTarget = "entryPoint",
};
}
[Fact]
public void PropertyModel_NotEqual_WhenDescriptionDiffers()
{
var a = new PropertyModel { JsonName = "x", CSharpName = "X", Description = "old" };
var b = new PropertyModel { JsonName = "x", CSharpName = "X", Description = "new" };
a.ShouldNotBe(b);
}
[Fact]
public void PropertyModel_StructurallyEqual_OnNestedItems()
{
PropertyModel Build() => new()
{
JsonName = "list",
CSharpName = "List",
Type = PropertyType.Array,
Items = new PropertyModel
{
JsonName = "item",
CSharpName = "Item",
Type = PropertyType.Ref,
Ref = "router",
},
};
Build().ShouldBe(Build());
}
}These look trivial, but they're the load-bearing safety net. The day someone adds a new field to PropertyModel and forgets to add it to Equals, the next test that builds two structurally-equal models with that new field set to different values will silently pass — and the cache will start returning stale .g.cs. The fix is to add the new field to both Equals and a new test in the same commit. The pattern is: for every field on the IR, there must be one test in IrEqualityTests that proves changing it makes two instances unequal.
What this buys you
After the fix:
- Editing whitespace in
TraefikSerializer.csre-runs the parser (Stage 1 has to read the file viaGetText), butSchemaModel.Equalsreturnstrueagainst the cached value, so Stage 2 is skipped and Stage 3's emit step is skipped. Zero.g.csregeneration. - Editing the actual JSON schema to add a new property re-runs Stage 1, produces a structurally different
SchemaModel,Equalsreturnsfalse, Stage 2 re-merges, Stage 3 re-emits. Exactly the cases where you want regeneration, and only those. - Adding a new field to
TraefikHttpRouterin C# code does not trigger the generator at all, because nothing in the AdditionalText input changed.
The cost is ~150 lines of Equals/GetHashCode boilerplate spread across SchemaModels.cs. The benefit is an editor that doesn't lock up every keystroke when you're working in a project that depends on the bundle. There is no free version of this; if you don't pay it, the IDE pays it for you.
← Part 3: Reading JSON Schemas · Next: Part 5 — Emitting Models →