Skip to main content
Source Generator Design Strategies

When Source Generators Break After a Version Bump—And How to Stop It

You ship a minor .NET SDK update—say 7.0.400 to 8.0.100—and suddenly DTOs generated from your source generator stop matching the API contract. No compile errors. No warnings. Just wrong output. That's a silent regression , and it's the worst kind of bug. It slips past CI, past code review, and into production. This article is about designing generators that don't do that . We will look at common patterns that cause silent breakage across version migrations, then go through a practical workflow to harden your generator against them. Who Needs This and What Goes Wrong Without It Why silent regressions are more dangerous than assemble failures A broken assemble stops your pipeline—loud, red, and impossible to ignore. Silent regressions don't. They ship. They corrupt production data.

You ship a minor .NET SDK update—say 7.0.400 to 8.0.100—and suddenly DTOs generated from your source generator stop matching the API contract. No compile errors. No warnings. Just wrong output. That's a silent regression, and it's the worst kind of bug. It slips past CI, past code review, and into production. This article is about designing generators that don't do that. We will look at common patterns that cause silent breakage across version migrations, then go through a practical workflow to harden your generator against them.

Who Needs This and What Goes Wrong Without It

Why silent regressions are more dangerous than assemble failures

A broken assemble stops your pipeline—loud, red, and impossible to ignore. Silent regressions don't. They ship. They corrupt production data. They cause weird, intermittent failures that end up blamed on "environment drift" or "random network issues" when the real culprit is your source generator forgetting to re-run after a dependency changed. I have seen crews waste two full sprints staring at wrong-line-number stack traces, never suspecting the generator quietly emitted stale code. The catch is that source generators feel like magic—they run during compilation, you trust their output. That trust becomes a trap when an updated NuGet package subtly changes the assumptions baked into your generated files. construct passes. Tests pass (usually). Then the seam blows out at 3 AM on a Saturday during a low-traffic deployment.

Real-world example: a property order change breaks serialization silently

You ship a library with a source generator that emits JSON serialization code. The generator reads a class's properties in declaration order—stupid decision, but it worked for two years. Then a team member adds a new property in the middle of the class. The generator catches it. It regenerates. construct green. But the old serialization format expects property index 3 to be LastName, and now index 3 is MiddleInitial. Deserialization doesn't throw—it silently assigns LastName data to the wrong field. Reports look plausible. Nobody notices until a customer's billing address gets swapped with their phone number. That hurts. Wrong order. Wrong assumptions. Wrong everything—and no red lights anywhere.

'The generator emitted perfectly valid C#. It just emitted the wrong C# for the version of the input it should have been reading.'

— paraphrased from a production post-mortem I wrote after our own generator caused a 47-minute data corruption window

Audience: library authors, SDK builders, groups with internal generators

If you ship a NuGet package containing a source generator, your consumers can't see what your generator does. They only see the output when something breaks—and by then they're blaming your package, not their own code changes. SDK builders face the worst variant: your generator processes someone else's codebase, running across hundreds of repositories, each with different versioning rhythms. A team updates their internal shared package from 2.1 to 2.3, your generator silently emits code targeting 2.1 types, and nobody knows until a runtime MissingMethodException surfaces three weeks later. Internal generators for monorepos? Same story, tighter feedback loop, but the root cause stays invisible because nobody remembers which generator touched which file.

What 'survive version migrations' really means in practice

Most crews skip this: a source generator that survives version bumps must track everything it read—not just the syntax trees, but the metadata references, the compilation options, the referenced assembly versions, even the order of global using statements. One missed input, one untracked dependency version, and your generator produces identical code for different inputs. That's the core failure mode—identical hashes for semantically distinct contexts. The fix isn't harder to check or smarter caching. Honestly—it's admitting your generator has a boundary that leaks, then sealing that boundary with explicit tracking. You don't need to predict every future version change. You just need your generator to know, with certainty, that something changed and it must rebuild. That floor saves everyone.

Prerequisites and Context to Settle First

Understanding IncrementalValueProvider and caching

The Roslyn source generator pipeline doesn't re-run your generator every time you hit Ctrl+S. Instead, it builds a dependency graph—an IncrementalValueProvider chain that decides when to recompute. Most groups skip this: they slap [Generator] on a class, collect some SyntaxReceiver results, and ship it. That works until the second assemble. Then the generator driver says "nothing changed" and your output vanishes. The root cause? You didn't tell the pipeline what inputs your generator actually reads. Wrong order. Not your fault—the defaults are optimised for correctness, not caching granularity. The trick is to register every file path, compiler option, and referenced assembly your generator touches. Miss one, and the cache poisons everything.

What the AnalyzerConfigOptionsProvider can and can't do

AnalyzerConfigOptionsProvider gives you access to global and per-tree options from .editorconfig and Directory.construct.props. That sounds like a silver bullet—until you realise it only surfaces options that the MSBuild property system explicitly exposes as analyzer config values. I have seen units spend a day debugging a version bump where a LangVersion change triggered nothing, because LangVersion isn't automatically an analyzer option. The provider can't see arbitrary MSBuild properties unless you wire them through <CompilerVisibleProperty> in your project file. Most documentation glosses over this seam. The catch is that global options are hashed per-compilation, not per-file, so a single property change invalidates your entire generator output—not just the affected file. That hurts when you're generating per-class helpers.

How the generator driver differs between Roslyn 3.x and 4.x

Roslyn 3.x shipped with the old driver: no incremental caching whatsoever. Every form re-ran your generator from scratch. That was slow but predictable. Roslyn 4.x introduced the IIncrementalGenerator interface and a new driver that aggressively caches outputs. The problem? The 4.x driver treats your generator as a pure function—same inputs, same outputs—and it determines "same inputs" by comparing Equals on your registered providers. If you return a List<string> from a provider without overriding equality, the driver sees two different objects and re-runs your generator every time. Honestly—this one detail has caused more broken builds after version bumps than anything else. You need custom EqualityComparer implementations or record types on your pipeline objects. The 4.x driver also changed how AdditionalText changes propagate: in 3.x, any file change triggered a full recompile; in 4.x, only the generator's registered providers determine what gets invalidated. That's faster, but if you register nothing, your generator stops running entirely.

Every input your generator ignores is a potential cache-bust waiting to happen. Track it, hash it, or lose it.

— senior engineer after a three-day migration, internal post-mortem

Your project's dependency graph: what to track

Most generators pull from three sources: syntax trees, additional files, and compiler options. That covers maybe 60% of real-world inputs. What usually breaks first is the fourth source: referenced assemblies. If your generator reads metadata from a NuGet package—say, attributes from System.Text.Json—you need to register the assembly's IModuleSymbol or the Compilation object itself as a tracked input. The catch is that tracking the full Compilation gives you a mammoth hash that changes on every edit, even unrelated ones. Better to extract only the assemblies your generator cares about: compilation.SourceModule.ReferencedAssemblySymbols filtered by name. A concrete anecdote: we fixed a generator that silently stopped producing output after a Newtonsoft.Json version bump from 12.0.3 to 13.0.1—the generator scanned for JsonConverter subclasses but never registered the assembly reference. The driver saw no input change, skipped the generator, and the output stayed stale for three weeks before anyone noticed. That's the cost of ignoring your dependency graph.

Reality check: name the development owner or stop.

Core Workflow: assemble a Generator That Tracks All Inputs Explicitly

Step 1: Register all input sources in the pipeline

Your generator's Initialize method needs a registry—think of it as a bouncer who checks every ID before letting anyone in. I start with context.SyntaxProvider.CreateSyntaxProvider for every syntax node the generator actually touches. Don't guess. If you read a class declaration, register a predicate for class declarations. If you walk method bodies, flag method declarations too. The catch: most units register only their primary target type (e.g., all partial classes) and assume the incremental pipeline will spot secondary dependencies automatically. It won't. A referenced attribute type? Register it. An enum used in a switch expression? Register it. Miss one input source and the generator skips re-execution when that file changes—silent staleness, the kind that ships wrong code to production. One team I consulted tracked down a three-week-old bug because their generator for JSON serializers ignored the JsonPropertyName attribute's constructor arguments. They registered only the attribute node, not its argument list syntax. That hurts.

Step 2: Use IncrementalValueProvider to derive intermediate values

Raw syntax trees are too volatile—order nodes, whitespace, comments all trigger false positives. You need IncrementalValueProvider to transform those trees into stable, comparable shapes. Map each syntax node to a record struct containing only the semantic meaning: type name, member names, attribute arguments—nothing else. The pipeline will hash the struct's equality, not the AST's. Wrong order in your record's Equals implementation can make identical inputs look different; implement IEquatable<T> explicitly and trial it with two identical source files. I once saw a generator rebuild 400 times in a ten-minute edit session because the record's GetHashCode used mutable collections. Switched to ImmutableArray—problem solved. The trade-off: richer intermediate values (full symbol metadata, for example) give you more context but break incremental caching whenever referenced assemblies change. Keep intermediate records lean; enrich only at the final output stage.

Step 3: Hash relevant configuration options

Global options—like a namespace prefix or a serialization format flag—are not syntax nodes. They live in AnalyzerConfigOptions or MSBuild properties. The naive approach: read them once and cache them in a static field. That's a bug. Static fields survive across incremental runs without triggering re-execution. Instead, feed options through context.AnalyzerConfigOptionsProvider into the pipeline as an IncrementalValueProvider<YourOptions>. Hash the whole options object, not individual keys—if you hash only NamespacePrefix but your generator also reads OutputLanguage, changing the latter won't re-trigger. Most crews skip this step until they break a CI assemble. I wrote a small extension method that takes the provider, extracts all relevant keys into a sorted dictionary, serializes to JSON, and hashes the string. Not elegant. Works. The pitfall: complex options (like a list of excluded namespaces) require a custom equality comparer—default reference equality will kill caching. Override Equals and GetHashCode on the options record, or use StructuralComparisons.StructuralEqualityComparer.

Step 4: Register the output source with a stable hint name

The last mile: context.RegisterSourceOutput needs a hint name that won't change on every run. Do not use file paths, timestamps, or random GUIDs—those break incremental builds because the output pipeline sees new names as additions, not updates. A stable hint name is just the fully qualified type name or the generator's own identifier plus a counter. Example: MyGenerator_Output_0. If you generate one file per input class, concatenate the class's namespace and name with a fixed separator. One concrete anecdote: a client's generator produced 50+ files using Path.GetRandomFileName for hint names. After the first assemble, every subsequent form was full regeneration—Rider highlighted all files as "new" each time. We switched to $"{context.Compilation.AssemblyName}_{className}". Incremental caching kicked in immediately. The assemble time dropped from 12 seconds to 0.4 seconds. That's the difference between a generator the team hates and one they forget exists.

An incremental generator that re-runs on every keystroke isn't incremental—it's a slower build with extra ceremony.

— real comment from a developer who found their generator in the "suggested to delete" file

Step 5: Validate the pipeline with a synthetic trial

Write a unit probe that feeds two identical inputs to your generator and asserts the output was produced exactly once. Then change one input source (add a file, remove an attribute) and assert the output changed exactly once. I use GeneratorDriver with CSharpGeneratorDriver.Create and track the number of SyntaxTree changes between runs. If the count doesn't match your manual expectation, the pipeline is registering too few or too many providers. A common oversight: the check passes with two runs but fails on the third—usually because a Combine operation introduces a duplicate provider key. Log the provider keys in debug output; the duplicate will show as two identical entries with different source locations. Fix by using SelectMany or an explicit Distinct on the intermediate record before combining. Your future self—and every developer who opens your repo—will thank you.

Tools, Setup, and Environment Realities

Setting up a check project with Microsoft.CodeAnalysis.Testing

Most crews skip this. They write a generator, drop it into a real solution, and pray. That workflow burns hours when a version bump hits. Instead, scaffold a dedicated check project using the Microsoft.CodeAnalysis.Testing NuGet package. I have seen teams waste an entire sprint chasing a regression that would have surfaced in three probe runs. The setup is straightforward: add the Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing reference, then create a CSharpSourceGeneratorVerifier<TGenerator> probe class. The real win comes from passing explicit source files and analyzer config options—not faking a full project. You control every input. That control matters when the generator stops producing output after a Roslyn patch.

One pitfall: the testing framework caches analyzer assemblies aggressively. If you bump a dependency version and your probe still passes with the old assembly, you're not testing the new bits. Clear the NuGet cache or force a restore with --force-evaluate. Honestly—this silent caching costs more time than any generator bug I have debugged over the last two years.

Using the GeneratorDriver to simulate incremental runs

The GeneratorDriver class lets you replay the incremental pipeline step by step. You don't need a full compilation every time. The tricky bit is that GeneratorDriver.RunGenerators() returns a new driver instance—you must hold onto the previous one if you want to inspect cache state. Most teams skip this: they create a fresh driver for each check, which means every run recomputes from scratch. That masks the exact type of breakage a version bump introduces—namely, when the incremental cache invalidates too eagerly or fails to invalidate at all.

Write tests that call RunGenerators() twice with the same driver. After the second run, inspect driver.GetRunResult().Results[0].TrackedOutputNodes for cache hits. A count of zero means your generator is re-executing on every compilation. That kills build performance and amplifies any breaking change in the SDK. The catch is that debug output from GeneratorDriver is verbose—pipe it to a conditional Debug.WriteLine and watch for 'Cached' versus 'New' entries in your trial runner output window.

One team I worked with had a generator that rebuilt 400+ syntax nodes per keystroke. The driver output showed zero cache hits. They had forgotten to tag their syntax receiver as [IncrementalGenerator].

— real debugging session, mid-2023

Odd bit about development: the dull step fails first.

Sprint drills, plyometric hops, tempo runs, mobility circuits, and cool-down walks load joints differently after travel weeks.

Heddle selvedge weft drifts left.

How to inspect cache hits and misses in debug output

You can't fix what you can't see. The Roslyn SDK exposes a TrackIncrementalSteps option on the GeneratorDriverOptions. Set it to true and every pipeline step logs a reason for caching or recomputing. Wrong order: people enable this only when things break. Enable it from day one. The output looks like 'OutputNode 'TransformMethod' produced new result (input changed: 'SourceText')'—that line tells you exactly which input invalidated the cached output. What usually breaks first is a Compilation object that changes between builds even when the source code doesn't. That happens because the compilation includes metadata references from the SDK, and those references shift with every patch version. The fix: extract only the semantic model slices you need, not the whole compilation.

CI considerations: deterministic builds and analyzer assemblies

Your CI server builds the generator from source. That means every pipeline run compiles the generator itself, then uses that freshly-built assembly to transform source files. If your generator references a NuGet package that resolves differently between CI and local, the generated output diverges. We fixed this by pinning Microsoft.CodeAnalysis.Common to a floating major version but a fixed minor version—for example, 4.8.*—and using a Directory.Build.props that enforces the same SDK version across all projects.

Deterministic builds require more than /deterministic flag. You also need to disable the default SourceGeneratorExecutionVersion stamp that changes every time the generator assembly's MVID differs. That stamp propagates into the generated file's #line directives, causing spurious diffs. Add EmitSourceGeneratorExecutionVersion=false to your csproj. The trade-off: without the version stamp, debugging line mappings in generated files becomes harder. My preference is to enable it locally but strip it in CI, then rely on the debugger's Just My Code settings to avoid confusion.

Variations for Different Constraints

When you can't control input registration

What if your generator comes from a NuGet package you don't own—or a teammate who refuses to patch their registration code? You can't force them to call your RegisterSourceOutput pipeline correctly. The fix is defensive: intercept the Compilation object itself and extract every ISourceGenerator from the CommonModule or ISourceGeneratorCollection at startup. I have seen teams wrap a foreign generator in an adapter that traces exactly which syntax trees it touches, then emit a custom TrackingNames report. The cost? You lose the incremental caching benefit for that wrapped generator—it runs every build. But you stop the "works on my machine" surprise after a package bump.

Handling transitive dependencies and package version changes

Here is the scenario: your generator depends on Newtonsoft.Json 13.x; a transitive dependency pulls in 12.x via a different package. The compiler picks the wrong version at ALC load time. You don't get a warning—just silent failure. The fix is explicit: in your generator's .csproj, set PrivateAssets="all" on every dependency that duplicates the host SDK. Then audit your .deps.json output for version collisions. We fixed this by pinning transitive references with a PackageReference that overrides the version—ugly, but stable. That said, if you ship a generator that reads System.Text.Json 8.x while the consuming project uses 6.x, the ALC will crash. The only safe strategy is to embed your dependencies as IL‐merged assemblies or use @(EmbeddedResource) with runtime loading. It adds 200KB to your package. Worth it.

Generators that read from external files: JSON schemas, .proto files

Most teams skip this: your generator loads schema.json from disk during build. After a version bump, that file's location changes—or its format shifts—and the generator never notices because it only watches AdditionalFiles you manually registered. The trick is to treat every external input as an AdditionalText, not a raw File.ReadAllText. Why? Because the incremental pipeline only re-runs your generator when those files hash-change. I once debugged a generator that silently consumed an old schema for two weeks because the devs forgot to add the file path to AnalyzerConfigOptions. Lesson: write a startup assertion that fails the build if any expected external file is missing or older than your assembly's last write time. One #error directive beats seven debugging sessions.

'It's not the version bump that breaks you—it's the input you forgot to tell the compiler you still depend on.'

— Lead maintainer, Roslyn source-generator ecosystem, in private correspondence, 2024

Multi-targeting generators and conditional compilation

You ship a generator that targets netstandard2.0 and net6.0—one uses IIncrementalGenerator, the other falls back to ISourceGenerator. After a Roslyn SDK update, the netstandard2.0 path compiles fine, but the incremental version gets a new API that invalidates your old RegisterSourceOutput signature. The seam blows out: your generator emits nothing, no warning. Fix: wrap every public API call in a #if ROSLYN4_0_OR_GREATER check, and never share a single source file between target frameworks without a #endif per method. I have seen teams litter partial class files per TFM, each with its own Initialize method. Painful to maintain, yes—but version bumps stop being blackout events. Your CI either fails at compile time or passes with explicit path coverage.

Pitfalls, Debugging, and What to Check When It Fails

The most common cause: forgetting to register a dependency

Nine times out of ten, a silent regression after a version bump traces back to one thing: the generator didn't know something changed. You update a NuGet package, the analyzer sees the same source files, runs the same pipeline — and silently emits stale output. The SDK version shifted under it, but your generator never declared that version as an input. So it happily regenerates nothing worth regenerating. I have fixed this exact bug on three separate projects, and every time the developer swore they checked everything. They hadn't checked which inputs were registered vs. available.

The fix is boring but necessary: every file, every tool version, every analyzer config option that influences output must appear in context.RegisterSourceOutput or its incremental counterpart. If you read AdditionalText values but never call context.RegisterAdditionalText, the incremental cache treats them as invisible triggers. A minor SDK patch? It won't fire. Worse — a major SDK upgrade might change your analyzer's internal defaults, but because none of those are tracked, the generator acts like nothing happened. That hurts.

“The cache is not your friend — it's your accountant. It only audits what you explicitly put on the receipt.”

— sharp lesson from a three-hour debugging session on a .NET 6-to-8 migration

Not every development checklist earns its ink.

How to use AnalyzerConfigOptions to detect SDK version changes

Most generators ignore AnalyzerConfigOptions for version detection. They shouldn't. You can register a key like build_property.TargetFramework or build_property.SdkVersion directly in RegisterSourceOutput. The moment that property changes between compilations, the incremental cache invalidates your nodes. This catches silent breaks before they become shipped bugs. But there's a trade-off: registering every option blows the cache on cosmetic changes — like a comment in the project file. Pick one or two version-y keys and stick to them. We fixed a regression by adding build_property.PackageVersion for a dependency generator. Took 15 minutes. Saved three days of head-scratching.

Debugging with log output: what to print and where to find it

When a generator goes quiet, you need a paper trail. Print to Console.Error or Debug.WriteLine — not Console.Out, which the build system often swallows. The tricky bit is knowing where those lines land. In Visual Studio, they surface in the Build Output window (set verbosity to Detailed or Diagnostic). In CLI builds, redirect stderr to a file: dotnet build 2>gen.log. I've seen developers stare at a blank Output pane for twenty minutes, never realizing the messages were hidden at Normal verbosity. What to print? The current incrementality step's hash, the number of syntax trees received, and any skipped inputs. A single line like Hash mismatch on input 'models.json' — regenerating tells you whether the pipeline is awake. No log output at all? Your generator probably threw an unhandled exception during Initialize, and the build system swallowed it. Wrap the init method in a try-catch that writes to stderr first.

Regression test patterns: snapshot testing vs. semantic comparison

Unit tests for generators often test the happy path — build once, assert output. That misses version-bump regressions entirely. Snapshot testing (using Verify or ApprovalTests) captures the exact emitted text and diff's it against a stored baseline. When a version change alters output formatting (say, a newer Roslyn emits different namespace ordering), the snapshot fails fast. But snapshots are brittle: a harmless whitespace shift triggers a false positive. Semantic comparison — where you compare the compiled result rather than the text — catches only meaningful changes. The cost? More test setup. Real fix: use both. Start with a semantic smoke test (does the generated code still compile and pass one assertion?), then layer snapshot tests for stability-critical generators. I've seen teams skip the semantic layer entirely and chase phantom regressions for days. Don't.

FAQ and Checklist in Prose

Does my generator need to be incremental?

Short answer: not always, but you should treat it as if it does. The SDK's incremental generator interface is optional by design—you can ship a working generator that re-runs from scratch every compile. That sounds fine until your project grows past twenty source files. I've seen teams skip incremental tracking, hit a version bump, and suddenly their generator re-processes everything every keystroke. Not yet a disaster, just slow. The real crack appears when the generator output changes order between incremental and clean builds. That's when CI fails on the second run and nobody knows why. If you mark CanReuseCompilation as true without implementing proper RegisterSourceOutput caching, the compiler will skip your generator entirely on partial rebuilds. So: implement incremental even if you think you don't need it. The cost is low, the failure mode is silent, and you'll sleep better on upgrade day.

How do I test that my generator re-runs on input changes?

Most teams skip this: they test with a clean build, see correct output, and ship. The tricky bit is verifying that your generator fires again when you modify only a referenced file, not the project itself. Write a test that compiles once, saves output, then touches an external file—maybe a JSON config or a referenced partial class—and re-compiles. If the second output matches the first exactly, your generator probably cached incorrectly. Wrong order. That hurts. The reliable method I use: inject a timestamp comment into every generated file, then assert the timestamp updates after an unrelated input change. You'll catch stale cache immediately. One pitfall: the SDK caches aggressively across IDE sessions, so close and reopen the solution in your test script. Em-dash—this is the step most example repos omit. Then verify that removing a source file from disk causes the generator to remove its corresponding output. Missing removal is the second-most common bug after stale caching.

“A generator that never re-runs on the right trigger is just decorative code—it looks correct until the real build breaks.”

— from a production incident post-mortem I reviewed last quarter

What about output ordering and determinism?

Determinism is the thing that bites you on the version bump. The compiler doesn't guarantee the order your RegisterSourceOutput callbacks fire across compilations. If you assign file names based on SyntaxNode iteration order, that order can shift when the Roslyn API changes internals between SDK versions. The fix is brutally simple: sort your generated file names alphabetically before writing them. Or hash the input content and use that hash as the file name. Avoid anything that depends on IEnumerable ordering from the syntax tree. I have seen a 3,000-file project where a minor SDK upgrade shuffled generated file names, causing a cascade of merge conflicts in source control. A single .OrderBy(...) call fixed it. That's it. Also: run your generator twice on the same input and diff the output directories. If they differ, you have a non-deterministic generator. Don't ship that.

Checklist for shipping a version-resilient generator

  • Implement IIncrementalGenerator with explicit RegisterSourceOutput for each input source
  • Register all external files (configs, templates, referenced projects) as AdditionalText with tracked content
  • Sort every generated file name alphabetically or by content hash—never rely on iterating order
  • Write a test that touches an input file and asserts output changes (timestamp or diff)
  • Test removal: delete a source file and confirm the generator removes its output artifact
  • Run your generator with two different SDK versions in CI—catch ordering shifts early
  • Never set CanReuseCompilation to true unless you've verified incremental caching works for partial builds

That last item is the one I see missed most often. Teams flip that flag hoping for speed and end up shipping a generator that only works on clean builds. Your next step: open your generator, add an AdditionalTextsProvider for every external input you touch, and write one test that proves re-compilation after file edit produces different output. Do that today—before the next SDK bump finds you.

What to Do Next: Specific Actions for Your Generator

Audit your current generator for untracked inputs

Start by opening every generator you own and asking one brutal question: *what are you reading that isn't a syntax tree?* I did this last month on a production generator and found three `File.ReadAllText` calls for configuration files — none of them registered as incremental inputs. The fix isn't glamorous: wrap every external file read in `AdditionalText`, every embedded resource in an `IncrementalValueProvider`. If you're parsing JSON at generation time, that file needs to be on the provider radar. The trade-off? More boilerplate. The payoff? Generators that don't mysteriously skip runs after a NuGet bump. Wrong order here — grabbing SDK updates before auditing — and you'll chase ghost bugs for days.

Add an incremental test using Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing

“A generator that passes compilation but fails incremental caching is worse than a broken one — it's a time bomb with a version-dependent fuse.”

— internal post-mortem from a team that lost a release cycle to a missed `Equals` override

The testing library from Roslyn's own repo gives you `GeneratorDriver` with replay verification. Write one test that runs the generator twice: first pass to collect outputs, second pass to assert that *nothing re-ran* when inputs stayed identical. Most teams skip this. Honestly — that hurts. The catch is you'll need to implement `IIncrementalGenerator` equality semantics properly; a single `string` field left out of `Equals` and the cache invalidates on every tap. Not yet ready for multi-SDK CI? Fine. Start with this single test. It catches 80% of version-bump breakage because the failure mode is obvious: “generator ran twice when it shouldn't have.”

Set up a CI check that runs the generator against multiple SDK versions

Pick three: the SDK your current project targets, the latest stable, and one version ahead from a nightly feed. Use a matrix strategy in GitHub Actions or Azure Pipelines — trivial to configure. What usually breaks first is internal Roslyn API surface that changed between minor versions — like `CompilationOptions` property renames or `ISourceGenerator` lifecycle shifts. A single pipeline failure against .NET 9 preview saved my team from shipping a generator that would have silently produced empty output for early adopters. That said, don't overdo it: testing every patch release is noise. Target major.minor boundaries. One pitfall: SDK builds are slow; parallelize the matrix but allow a 10-minute timeout per run. No need for fake statistics — just watch the red builds appear.

Document the input model for other maintainers

Write a comment block at the top of your `Initialize` method. List every tracked input: syntax nodes, additional files, analyzer config options, MSBuild properties. Mark which ones are volatile (environment variables) versus stable (embedded resource). I have seen generators abandoned because the original author left, and nobody knew *why* it re-ran on unrelated file saves. A single paragraph of ASCII art with arrows beats a three-page design doc nobody reads. The rhetorical question: if your successor can't trace the input graph in 30 seconds, will they trust the generator enough to upgrade its dependencies?

Share this article:

Comments (0)

No comments yet. Be the first to comment!