Source Generator & Analyser Best Practices
Source Generator & Analyser Best Practices
Section titled “Source Generator & Analyser Best Practices”Practical guidance for writing Roslyn analysers and incremental source generators that remain fast, deterministic, cache-friendly, IDE-compatible, and safe to distribute.
Contents
Section titled “Contents”- 1. Core Principles
- 2. Analyser or Source Generator?
- 3. Choosing an Analyser Action
- 4. Syntax vs Symbol vs Operation
- 5. Analyser Best Practices
- 6. Incremental Generator Golden Rules
- 7. Pipeline Value Equality
- 8. Designing the Incremental Pipeline
- 9. Syntax Discovery
- 10.
Collect,Combine, and Invalidation - 11. Diagnostics
- 12. Output Generation
- 13. Testing Incrementally
- 14. Roslyn Version Compatibility
- 15. Visual Studio, .NET SDK, and Rider
- 16. Multi-Version Roslyn Packaging
- 17.
Microsoft.CodeAnalysis.Analysers - 18. Recommended Project Configuration
- 19. Extension Class Conventions
- 20. Review Checklist
1. Core Principles
Section titled “1. Core Principles”The most important rules are:
- Use an analyser to validate user code.
- Use an incremental generator to generate code.
- Use
ForAttributeWithMetadataNamefor attribute-driven generators. - Remove Roslyn objects from the incremental pipeline as early as possible.
- Every value crossing a pipeline boundary should have meaningful value equality.
- Prefer many small incremental stages over one large transform.
- Keep broad inputs such as
Compilationaway from downstream generation. - Generate deterministic output.
- Compile against the oldest Roslyn API version you actually need.
- Test caching, not just generated text.
The guiding principle for an incremental generator is:
Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model.
2. Analyser or Source Generator?
Section titled “2. Analyser or Source Generator?”Analysers and generators have different responsibilities.
A DiagnosticAnalyser answers:
Is the source code valid according to this library’s rules?
An IIncrementalGenerator answers:
Given valid source code, what source should be generated?
Decision Table
Section titled “Decision Table”| Requirement | Prefer | Reason |
|---|---|---|
Require a class to be partial |
Analyser | User-code contract violation |
| Require an attribute on a declaration | Analyser | User-code contract violation |
| Validate a method signature | Analyser | Semantic validation |
| Reject unsupported property types | Analyser | Better IDE feedback |
| Detect invalid attribute arguments | Analyser | Natural diagnostic |
| Detect unsupported API usage | Analyser | Operation analysis |
| Offer an automatic fix | Analyser + CodeFixProvider |
Code fixes operate on diagnostics |
| Generate members for a marked class | Incremental generator | Generation |
| Generate serializers/validators/mappers | Incremental generator | Generation |
| Generate a registry from discovered types | Incremental generator | Generation |
| Read an additional schema file and generate C# | Incremental generator | Generation |
| Report an unexpected internal generation failure | Generator diagnostic | Generation-specific failure |
| Validate generator-only external input | Generator diagnostic may be appropriate | Analyser may not see equivalent input |
Prefer:
User Source │ ├── Analyser │ ├── discovers relevant source │ ├── validates the contract │ ├── reports diagnostics │ └── optionally provides code fixes │ └── Incremental Generator ├── discovers relevant source ├── extracts semantic values ├── creates equatable models └── generates deterministic sourceA useful shorthand is:
Analysers protect the contract. Generators implement the contract.
3. Choosing an Analyser Action
Section titled “3. Choosing an Analyser Action”Use the narrowest analyser API that directly represents the thing being analysed.
Do not start with a broad syntax scan if Roslyn already exposes the concept as a symbol or operation.
Analyser Action Decision Matrix
Section titled “Analyser Action Decision Matrix”| API | Use when | Examples | Recommendation |
|---|---|---|---|
RegisterSyntaxNodeAction |
Exact source syntax matters | Modifier presence, declaration form | Use for syntax rules |
RegisterSymbolAction |
A declaration’s semantic meaning matters | Accessibility, attributes, implemented interfaces | Preferred for declaration rules |
RegisterOperationAction |
Executable behaviour/API usage matters | Invocation, assignment, conversion, object creation | Preferred for semantic usage rules |
RegisterOperationBlockStartAction |
Multiple operations inside one body need shared state | Track resource use throughout a method | Use for stateful method analysis |
RegisterOperationBlockAction |
Whole executable body must be analysed | Final body-level validation | Prefer narrower actions where possible |
RegisterCodeBlockStartAction |
Syntax-oriented body analysis needs state | Stateful syntax rules | Less common than operation-block analysis |
RegisterCodeBlockAction |
Entire syntax code block matters | Body-level syntax rules | Use sparingly |
RegisterSymbolStartAction |
A symbol and its members must be analysed together | Type-wide analysis across members | Powerful but relatively expensive |
| Symbol end action | Result depends on all child/member analysis | Report once for entire type | Register from symbol-start |
RegisterCompilationStartAction |
Expensive semantic setup should happen once | Resolve known framework/library symbols | Good initialization boundary |
| Compilation end action | A result genuinely depends on the entire compilation | Global collision/aggregate rule | Avoid unless necessary |
RegisterAdditionalFileAction |
Analyze AdditionalFiles |
Config/schema validation | Correct abstraction |
RegisterSemanticModelAction |
Analysis genuinely applies to an entire semantic model | Rare tree-wide semantic rule | Usually too broad |
RegisterSyntaxTreeAction |
Entire raw syntax tree matters | File header / file-level syntax | Prefer node actions when possible |
4. Syntax vs Symbol vs Operation
Section titled “4. Syntax vs Symbol vs Operation”The most common analyser design decision is choosing between:
- syntax;
- symbols;
- operations.
Quick Decision
Section titled “Quick Decision”Does exact source spelling/structure matter? │ ├── Yes ──► Syntax │ └── No │ ├── Is this a declaration? │ └── Yes ──► Symbol │ └── Is this executable behaviour? └── Yes ──► OperationSyntax
Section titled “Syntax”Use syntax when the literal structure of the user’s source matters.
Examples:
- is
partialexplicitly present? - did the user write a primary constructor?
- is the namespace file-scoped?
- was an explicit modifier specified?
- is a declaration syntactically structured in a particular way?
Example:
context.RegisterSyntaxNodeAction( AnalyzeClass, SyntaxKind.ClassDeclaration);Syntax is often the cheapest solution when no semantic information is required.
Do not ask the semantic model a question that can be answered directly from syntax.
Symbols
Section titled “Symbols”Use symbols when analysing declarations semantically.
Examples:
- does this type implement
IDisposable? - does this member have a particular attribute?
- what is the method return type?
- what is the property’s accessibility?
- what generic type arguments are present?
- is this type abstract?
- which containing namespace owns this type?
Example:
context.RegisterSymbolAction( AnalyzeNamedType, SymbolKind.NamedType);When comparing symbols:
SymbolEqualityComparer.Default.Equals(left, right)should normally be used rather than reference equality.
Operations
Section titled “Operations”Use IOperation when analysing executable semantics.
Examples:
- method invocation;
- constructor invocation;
- assignment;
- conversion;
- property access;
- field access;
- argument passing;
- return values;
await;- binary/unary operations.
For example:
context.RegisterOperationAction( AnalyzeInvocation, OperationKind.Invocation);Then:
static void AnalyzeInvocation(OperationAnalysisContext context){ var invocation = (IInvocationOperation)context.Operation;
var method = invocation.TargetMethod;
// Semantic method information is already available.}This is normally preferable to:
context.RegisterSyntaxNodeAction( AnalyzeInvocation, SyntaxKind.InvocationExpression);followed by:
context.SemanticModel.GetSymbolInfo(...)for every invocation.
Common Operation Kinds
Section titled “Common Operation Kinds”| Requirement | Operation |
|---|---|
| Method invocation | OperationKind.Invocation |
| Constructor invocation | OperationKind.ObjectCreation |
| Assignment | OperationKind.SimpleAssignment |
| Compound assignment | compound assignment operation kinds |
| Argument validation | OperationKind.Argument |
| Property access | OperationKind.PropertyReference |
| Field access | OperationKind.FieldReference |
| Conversion | OperationKind.Conversion |
| Return expression | OperationKind.Return |
| Await | OperationKind.Await |
| Binary expression | OperationKind.Binary |
| Unary expression | OperationKind.Unary |
Use the semantic operation rather than reconstructing equivalent information from syntax whenever possible.
5. Analyser Best Practices
Section titled “5. Analyser Best Practices”Enable Concurrent Execution
Section titled “Enable Concurrent Execution”Analysers should normally enable concurrent execution:
public override void Initialize(AnalysisContext context){ context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis( GeneratedCodeAnalysisFlags.None );
// Register actions.}Analyser callbacks can execute concurrently.
Avoid shared mutable state.
Configure Generated Code Explicitly
Section titled “Configure Generated Code Explicitly”Do not leave generated-code handling implicit.
For most library contract analysers:
context.ConfigureGeneratedCodeAnalysis( GeneratedCodeAnalysisFlags.None);is appropriate.
Only inspect generated code if the analyser explicitly needs to.
Resolve Known Types Once
Section titled “Resolve Known Types Once”If an analyser needs to repeatedly compare against known framework or library types, resolve them during compilation start.
context.RegisterCompilationStartAction(static context =>{ var targetType = context.Compilation.GetTypeByMetadataName( "MyLibrary.SomeType" );
if (targetType is null) return;
context.RegisterOperationAction( c => AnalyzeInvocation(c, targetType), OperationKind.Invocation );});This is a good reason to use CompilationStart.
Do not use CompilationStart merely to enumerate the entire compilation.
Prefer Narrow Registration
Section titled “Prefer Narrow Registration”Prefer:
context.RegisterOperationAction( AnalyzeInvocation, OperationKind.Invocation);over an action that sees every operation.
Prefer:
context.RegisterSyntaxNodeAction( AnalyzeClass, SyntaxKind.ClassDeclaration);over scanning an entire SyntaxTree.
6. Incremental Generator Golden Rules
Section titled “6. Incremental Generator Golden Rules”Implement:
IIncrementalGeneratorrather than the legacy:
ISourceGeneratorBut simply implementing IIncrementalGenerator does not make a generator meaningfully incremental.
Incrementally depends on the equality behaviour of the values flowing through the pipeline.
The Golden Rule
Section titled “The Golden Rule”Pipeline values must be immutable and value-equatable.
Roslyn needs to determine:
Did this pipeline stage produce the same logical value as last time?If the answer is yes, Roslyn can stop executing downstream stages and reuse cached results.
7. Pipeline Value Equality
Section titled “7. Pipeline Value Equality”Pipeline Red List
Section titled “Pipeline Red List”These should not normally survive into your generator model.
| Type | Verdict | Why |
|---|---|---|
ISymbol |
❌ Never retain | Not suitable for pipeline equality; can retain old compilations |
INamedTypeSymbol |
❌ Never retain | Same problem as ISymbol |
IMethodSymbol |
❌ Never retain | Same problem as ISymbol |
IPropertySymbol |
❌ Never retain | Same problem as ISymbol |
Compilation |
❌ Do not propagate | Huge semantic graph and broad invalidation source |
SemanticModel |
❌ Do not propagate | Bound to compilation/tree |
IOperation |
❌ Do not propagate | Compiler semantic graph object |
SyntaxTree |
❌ Do not propagate | Changes with source edits |
SyntaxNode |
⚠ Remove ASAP | Usually loses equality after edits to its tree |
Location |
⚠ Remove ASAP | Same incrementally problem as syntax |
AdditionalText |
⚠ Project immediately | Host/compiler input object |
T[] |
❌ Avoid in models | Reference equality |
List<T> |
❌ Avoid in models | Mutable and reference equality |
ImmutableArray<T> |
⚠ Wrap/compare explicitly | Immutable but not sequence-value-equatable for model equality |
| Mutable class | ❌ Avoid | Reference equality unless explicitly implemented |
Good Model
Section titled “Good Model”internal sealed record TypeModel( string Namespace, string Name, string FullyQualifiedName, Accessibility Accessibility, EquatableArray<PropertyModel> Properties);
internal sealed record PropertyModel( string Name, string FullyQualifiedTypeName, bool IsNullable);Bad Model
Section titled “Bad Model”internal sealed record TypeModel( INamedTypeSymbol Symbol, Compilation Compilation, Location Location, ImmutableArray<IPropertySymbol> Properties);Making the outer object a record does not magically make its members suitable for incremental equality.
ImmutableArray<T> Is Not Enough
Section titled “ImmutableArray<T> Is Not Enough”ImmutableArray<T> solves:
Can this collection be mutated?
It does not automatically solve:
Do two separately-created collections containing equivalent elements compare as the same sequence for my pipeline model?
These are different problems.
For incremental models, prefer something such as:
EquatableArray<T>with sequence-based equality.
Conceptually:
internal readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>{ private readonly ImmutableArray<T> _items;
public bool Equals(EquatableArray<T> other) { if (_items.Length != other._items.Length) return false;
return _items .AsSpan() .SequenceEqual(other._items.AsSpan()); }
public override bool Equals(object? obj) => obj is EquatableArray<T> other && Equals(other);
public override int GetHashCode() { var hash = new HashCode();
foreach (var item in _items) hash.Add(item);
return hash.ToHashCode(); }}The precise implementation can vary.
The important requirement is:
same contents => equal pipeline value8. Designing the Incremental Pipeline
Section titled “8. Designing the Incremental Pipeline”Think of every transformation as a cache checkpoint.
Prefer:
Roslyn Input │ ▼Cheap discovery │ ▼Semantic extraction │ ▼Small equatable model │ ▼Validation/transformation │ ▼Generation model │ ▼Source outputDo not do:
Roslyn Input │ ▼Giant transform containing symbols + syntax + compilation │ ▼Generate everythingProject Early
Section titled “Project Early”The semantic transform should usually be the boundary where Roslyn objects disappear.
Example:
static TypeModel CreateModel( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken){ var symbol = (INamedTypeSymbol)context.TargetSymbol;
return new TypeModel( Namespace: symbol.ContainingNamespace.ToDisplayString(),
Name: symbol.Name,
FullyQualifiedName: symbol.ToDisplayString( SymbolDisplayFormat.FullyQualifiedFormat ),
Accessibility: symbol.DeclaredAccessibility );}Everything downstream should receive TypeModel, not INamedTypeSymbol.
Prefer Static Lambdas
Section titled “Prefer Static Lambdas”Prefer:
.Select(static (value, cancellationToken) =>{ return Transform(value, cancellationToken);});Static callbacks prevent accidental capture of generator instance state.
Generator instances should not be treated as application services or state containers.
Honour Cancellation
Section titled “Honour Cancellation”For non-trivial transformations:
.Select(static (value, cancellationToken) =>{ cancellationToken.ThrowIfCancellationRequested();
return Transform(value, cancellationToken);});Pass cancellation tokens into Roslyn APIs that accept them.
Split Transformations
Section titled “Split Transformations”Prefer:
Syntax ↓Symbol projection ↓Type model ↓Property models ↓Generation model ↓Outputover:
Syntax ↓Do absolutely everything ↓OutputMore meaningful boundaries give Roslyn more opportunities to short-circuit downstream processing.
9. Syntax Discovery
Section titled “9. Syntax Discovery”Prefer ForAttributeWithMetadataName
Section titled “Prefer ForAttributeWithMetadataName”Attribute-driven generation should normally start with:
context.SyntaxProvider.ForAttributeWithMetadataName( fullyQualifiedMetadataName: "MyLibrary.GenerateAttribute",
predicate: static (node, _) => node is TypeDeclarationSyntax,
transform: static (context, cancellationToken) => CreateModel(context, cancellationToken));Advantages include:
- highly optimized discovery;
- alias support;
- direct
TargetSymbol; - matching
AttributeData; - obvious user intent;
- easier analyser integration.
For marker-attribute generators, this should be the default.
Use CreateSyntaxProvider When Syntax Is Actually the Trigger
Section titled “Use CreateSyntaxProvider When Syntax Is Actually the Trigger”Use:
context.SyntaxProvider.CreateSyntaxProvider(...)when there is no appropriate marker attribute.
Examples:
- syntax-driven DSL;
- a generator intentionally driven by a language construct;
- a pattern that cannot reasonably use an attribute.
The predicate must be cheap.
Good:
predicate: static (node, _) => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }Bad:
predicate: static (node, _) => { // expensive walking // semantic work // allocations // string construction return true; }The predicate runs extremely frequently.
Semantic work belongs in the transformation callback.
Avoid Indirect Discovery
Section titled “Avoid Indirect Discovery”Avoid designs that require discovering:
- every indirect implementation of an interface;
- every indirect subclass;
- inherited marker attributes through arbitrary hierarchies;
- every type in a compilation followed by manual filtering.
A change high in a type hierarchy can invalidate a large arbitrary portion of the compilation.
Prefer explicit intent:
[GenerateSchema]partial class Customer{}over:
Generate everything somewhere downstream of IBaseSchemaThing10. Collect, Combine, and Invalidation
Section titled “10. Collect, Combine, and Invalidation”Collect()
Section titled “Collect()”Collect() transforms:
IncrementalValuesProvider<T>into roughly:
IncrementalValueProvider<ImmutableArray<T>>This changes invalidation scope.
Before:
A ──► output AB ──► output BC ──► output CAfter collection:
A ─┐B ─┼──► [A,B,C] ──► outputC ─┘Changing B changes the aggregate [A,B,C].
Prefer Per-Item Output
Section titled “Prefer Per-Item Output”Prefer:
context.RegisterSourceOutput( models, static (context, model) => Emit(context, model));instead of:
context.RegisterSourceOutput( models.Collect(), static (context, models) => { foreach (var model in models) Emit(context, model); });unless the generation genuinely requires the complete set.
Good Uses of Collect()
Section titled “Good Uses of Collect()”Use Collect() when generating something intrinsically global:
- one registry containing every handler;
- one lookup containing every generated type;
- duplicate-name detection across all targets;
- one aggregate switch;
- one generated dependency map.
A useful design is:
┌──► Per-type sourceType Models ────────┤ │ └──► Collect() │ ▼ Global registryOnly the registry should pay the global invalidation cost.
Combine()
Section titled “Combine()”Use Combine() when one output logically depends on two providers.
Example:
var generationInput = typeModels.Combine(generatorOptions);That means:
type changed ───────┐ ├──► generation invalidatedoption changed ─────┘This is correct if either input should regenerate the output.
Be Very Careful Combining CompilationProvider
Section titled “Be Very Careful Combining CompilationProvider”This:
models.Combine(context.CompilationProvider)is often an incremental performance smell.
Almost any semantic change can replace the compilation.
If possible, project the compilation into the tiny fact you actually need:
var capabilities = context.CompilationProvider .Select(static (compilation, _) => new CompilationCapabilities( HasRequiredType: compilation.GetTypeByMetadataName( "MyLibrary.RequiredType" ) is not null ) );Then:
models.Combine(capabilities)At least downstream equality can now short-circuit when the relevant capability did not change.
WithComparer()
Section titled “WithComparer()”Roslyn provides:
.WithComparer(...)when default equality is insufficient.
Example:
provider.WithComparer( MyModelComparer.Instance);Use this when your logical equality differs from the default implementation.
Do not use it as a way to justify retaining large compiler objects inside the model.
This is suspicious:
record Model( INamedTypeSymbol Symbol, Compilation Compilation);followed by an elaborate comparer.
The better solution is usually to redesign Model.
11. Diagnostics
Section titled “11. Diagnostics”Prefer a Separate Analyser
Section titled “Prefer a Separate Analyser”Normal user validation belongs in a DiagnosticAnalyser.
Benefits include:
- immediate IDE feedback;
- independent execution from generation;
- easier testing;
- code-fix support;
- simpler incremental generator pipelines.
Generator Diagnostics Are Still Valid
Section titled “Generator Diagnostics Are Still Valid”Generator diagnostics make sense for things such as:
- malformed additional files;
- invalid generator-only configuration;
- conflicting generated output discovered only during generation;
- failures that cannot naturally be expressed by a separate analyser.
Do not turn the generator pipeline into an analyser pipeline by default.
Blocking vs Non-Blocking Generator Diagnostics
Section titled “Blocking vs Non-Blocking Generator Diagnostics”GeneratorResult<T>.ShouldProcess decides whether the output stage runs for a target. It is true
when the result carries a value and none of its carried ReportableDiagnostic diagnostics are blocking.
Whether a diagnostic blocks is an explicit, per-diagnostic decision (ReportableDiagnostic.IsBlocking),
independent of its severity. An Error-severity diagnostic can still allow generation to continue when
the generated code helps the developer fix the problem — for example, emitting an abstract base class
alongside an error for a missing override, so the user can see what to implement:
var diagnostic = ReportableDiagnostic.Create( MissingOverride, isBlocking: false, // report the error, but keep generating symbol, symbol.Name, "Execute");
return GeneratorResult<BaseModel>.Create(model, diagnostic);A blocking diagnostic (isBlocking: true) stops generation for that target while still being reported.
Prefer blocking diagnostics for genuine contract violations that would produce misleading output; prefer
non-blocking diagnostics when the partial output is still useful.
Location Handling
Section titled “Location Handling”Analysers should report diagnostics on the most useful user-authored Location.
Generators should avoid keeping Location inside long-lived pipeline models.
If a generator absolutely requires source position information, convert it into a value model:
internal readonly record struct SourceLocationModel( string FilePath, int Start, int Length);But even this should only be carried downstream if generation actually depends on the location.
12. Output Generation
Section titled “12. Output Generation”Output Must Be Deterministic
Section titled “Output Must Be Deterministic”For the same generator model:
input model ↓identical generated sourceAvoid:
- current timestamps;
- random GUIDs;
- process IDs;
- machine-specific paths;
- unordered dictionary output;
- machine environment variables;
- current culture affecting generation.
Deterministic Hint Names
Section titled “Deterministic Hint Names”Good:
context.AddSource( $"{model.HintName}.g.cs", source);Bad:
context.AddSource( $"{Guid.NewGuid():N}.g.cs", source);Hint names must be:
- deterministic;
- unique within the generator;
- stable when irrelevant source changes.
Prefer Text Generation
Section titled “Prefer Text Generation”Do not build a complete Roslyn syntax tree merely to generate source unless there is a strong reason.
For generator output, a small code writer or structured string builder is generally easier and faster.
Avoid repeatedly doing:
syntax.NormalizeWhitespace().ToFullString()for large generated trees.
Generated output may use C# 14 features — extension-member blocks (extension(...), via
CodeWriter.ExtensionBlockScope), the field keyword, collection expressions — when the target
compilation supports them. The framework is built against Roslyn 5.x, so its generators may emit C# 14
output; consumers need a matching compiler (.NET 10 SDK / Roslyn 5.0 or later) to compile it. Gate any
newer-than-baseline features on GenerationSettings.LanguageVersion when a generator must also serve
older hosts.
Post-Initialization Output
Section titled “Post-Initialization Output”Use:
RegisterPostInitializationOutputfor source that is constant regardless of the user’s compilation.
Examples:
- marker attributes;
- fixed helper attributes;
- static support types.
Example:
context.RegisterPostInitializationOutput( static context => { context.AddSource( "GenerateAttribute.g.cs", SourceText.From( """ // <auto-generated/>
namespace MyLibrary;
[global::System.AttributeUsage( global::System.AttributeTargets.Class, AllowMultiple = false, Inherited = false)] internal sealed class GenerateAttribute : global::System.Attribute { } """, Encoding.UTF8 ) ); });13. Testing Incrementally
Section titled “13. Testing Incrementally”Snapshot-testing generated source is not sufficient.
A generator can generate perfectly correct code while defeating almost all incremental caching.
Test both:
Correctness+IncrementallyTest Cases
Section titled “Test Cases”At minimum test:
- first execution produces expected output;
- identical second execution is cached;
- unrelated source changes remain cached;
- changing one target only invalidates that target;
- changing one property only invalidates dependent stages;
- deleting a target removes its output;
- renaming a target changes the expected hint/source;
- changing global generator options invalidates appropriate output;
- changing an additional file invalidates only dependent output;
- global registry generation invalidates when expected.
Track Incremental Generator Steps
Section titled “Track Incremental Generator Steps”Create the generator driver with tracking enabled.
For example:
var driverOptions = new GeneratorDriverOptions( disabledOutputs: IncrementalGeneratorOutputKind.None,
trackIncrementalGeneratorSteps: true );Inspect tracked output reasons such as:
NewModifiedUnchangedCachedRemovedThe exact reason expected depends on the stage and test scenario.
The important point is that tests should prove:
An unrelated edit does not rerun expensive downstream generation.
Framework support
Section titled “Framework support”The framework’s testing packages make step-cache tests first-class. SourceGeneratorTestRunner.RunIncrementalAsync
runs one shared driver over a sequence of source sets with step tracking enabled, and every pipeline helper
assigns a tracking name so tests can reference individual stages.
var result = await new SourceGeneratorTestRunner<ServiceRegistrationGenerator>().RunIncrementalAsync( [ new IncrementalRunInput([firstSources]), new IncrementalRunInput([changedSources]), ], options, cancellationToken);
await Assert.That(result.Runs[0]).AllStepsNew();await Assert.That(result.Runs[1]).StepIsCached("ForAttribute_GenerateServiceAttribute");await Assert.That(result.Runs[1]).StepIsModified("GetGenerationConfiguration");Assertions on IncrementalCacheRun (AllStepsNew, AllStepsCachedOrUnchanged, StepIsCached,
StepIsModified, HasStepReason) plus GetStepReasons() cover the golden matrix. See
docs/step-cache-tests.md for the full walkthrough and the canonical
StepCacheTests.cs sample in the ExampleGenerator unit tests.
14. Roslyn Version Compatibility
Section titled “14. Roslyn Version Compatibility”The most important packaging rule is:
The version of
Microsoft.CodeAnalysis.*used to compile your analyser/generator establishes a minimum compiler-host API requirement.
The consumer’s:
<TargetFramework>...</TargetFramework>does not determine analyser compatibility.
Analyser/generator code executes inside a compiler/IDE host.
Roslyn / Visual Studio Compatibility
Section titled “Roslyn / Visual Studio Compatibility”Microsoft’s published compatibility baseline is:
| Roslyn package | Minimum Visual Studio | Language / .NET generation |
|---|---|---|
| 4.0.1 | VS 2022 17.0 | C# 10 / .NET 6 |
| 4.1 | VS 2022 17.1 | C# 10 / .NET 6 |
| 4.2 | VS 2022 17.2 | C# 10 / .NET 6 |
| 4.3.1 | VS 2022 17.3 | C# 10 / .NET 6 |
| 4.4 | VS 2022 17.4 | C# 11 / .NET 7 |
| 4.5 | VS 2022 17.5 | C# 11 / .NET 7 |
| 4.6 | VS 2022 17.6 | C# 11 / .NET 7 |
| 4.7 | VS 2022 17.7 | C# 11 / .NET 7 |
| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 |
| 4.9.2 | VS 2022 17.9 | C# 12 / .NET 8 |
| 4.10 | VS 2022 17.10 | C# 12 / .NET 8 |
| 4.11 | VS 2022 17.11 | C# 12 / .NET 8 |
| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 |
| 4.13 | VS 2022 17.13 | C# 13 / .NET 9 |
| 4.14 | VS 2022 17.14 | C# 13 / .NET 9 |
| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 |
This table gives the minimum documented Visual Studio host.
This framework is built against Roslyn 5.0. The generator, analyzer, and testing assemblies in
Purview.SourceGeneratorFramework*are compiled againstMicrosoft.CodeAnalysis5.x, so compiler hosts that load them must be Roslyn 5.0 or later (.NET 10SDK / Visual Studio 2026 18.0). The testing packages multi-targetnet8.0–net10.0; Roslyn 5.x shipsnet8.0/net9.0package assets, so those test targets still load the test runner.
Do not interpret it as:
net8.0 application = Roslyn 4.8 analyserThat is incorrect.
Example
Section titled “Example”A project may target:
<TargetFramework>net8.0</TargetFramework>while being compiled by:
Visual Studio 2026 / Roslyn 5.xAn analyser compiled against Roslyn 5.0 may therefore work.
The same net8.0 project opened in:
Visual Studio 2022 17.8 / Roslyn 4.8cannot be assumed to load that Roslyn-5.0-based analyser.
The application TFM did not change.
The compiler host did.
15. Visual Studio, .NET SDK, and Rider
Section titled “15. Visual Studio, .NET SDK, and Rider”Safe Roslyn Baselines
Section titled “Safe Roslyn Baselines”Choose the oldest Roslyn version containing the APIs you require.
Typical baseline choices are:
| Minimum tooling you intend to support | Maximum baseline you should normally compile against |
|---|---|
| VS 2022 17.8 / initial .NET 8 generation | Roslyn 4.8 |
| VS 2022 17.10 | Roslyn 4.10 |
| VS 2022 17.12 / initial .NET 9 generation | Roslyn 4.12 |
| VS 2022 17.14 | Roslyn 4.14 |
| VS 2026 18.0 / initial .NET 10 generation | Roslyn 5.0 |
If you compile against a later package, you have deliberately raised your minimum host requirement unless you have proven otherwise.
.NET SDK
Section titled “.NET SDK”The .NET SDK contains a compiler toolchain.
Broad release alignment is:
.NET 8 / C# 12 ──► Roslyn 4.8 generation.NET 9 / C# 13 ──► Roslyn 4.12 generation.NET 10 / C# 14 ──► Roslyn 5.0 generationHowever, SDK servicing and feature bands can contain later compiler versions.
Therefore do not use:
TargetFramework == net10.0as proof that a particular Roslyn API is available to your analyser.
Likewise:
$(TargetFramework)should not be used to choose the analyser binary.
The relevant variable is the compiler host.
Rider supports:
- Roslyn analysers;
- source generators;
- generated-source navigation;
- analyser diagnostics;
- analyser quick fixes;
- source generator execution.
However, JetBrains does not publish the same simple:
Rider Version => Maximum Microsoft.CodeAnalysis Versionmatrix that Microsoft publishes for Visual Studio.
Therefore:
Do not invent a Rider/Roslyn version mapping.
If Rider support is part of your package contract:
- choose a conservative Roslyn baseline;
- test the oldest Rider version you support;
- test
dotnet build; - test Rider design-time generation;
- test generated-source navigation;
- test analysers and code fixes where applicable.
Build-time compiler compatibility and Rider IDE integration should be tested independently.
16. Multi-Version Roslyn Packaging
Section titled “16. Multi-Version Roslyn Packaging”This area is frequently misunderstood.
NuGet Analyser Assets Are Not Normal TFM Assets
Section titled “NuGet Analyser Assets Are Not Normal TFM Assets”Normal runtime/library assets support selection such as:
lib/net8.0/lib/net9.0/lib/net10.0/Analyser assets conventionally live under:
analysers/ dotnet/ cs/ MyGenerator.dllThis is not a general-purpose:
Roslyn 4.8Roslyn 4.14Roslyn 5.0selection mechanism.
Do not place multiple Roslyn-targeted implementations into the ordinary analyser folder and expect NuGet to automatically choose the correct one.
Strategy 1 — One Conservative Binary
Section titled “Strategy 1 — One Conservative Binary”Recommended Default
Section titled “Recommended Default”Compile against the oldest Roslyn version required by your implementation.
For example:
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />Package:
analysers/ dotnet/ cs/ MyGenerator.dllAdvantages:
- simplest;
- predictable;
- broadest compatibility;
- works naturally with IDEs;
- minimal packaging logic.
Disadvantage:
- cannot statically call newer Roslyn APIs.
For most public generators, this is the correct approach.
Strategy 2 — Raise the Package Baseline
Section titled “Strategy 2 — Raise the Package Baseline”If a newer Roslyn feature materially improves the generator, it may be better to explicitly raise the minimum compiler version.
For example:
MyGenerator 2.x Roslyn >= 4.8
MyGenerator 3.x Roslyn >= 5.0Document the minimum IDE/compiler requirement.
This is much easier for users to reason about than hidden runtime selection.
Strategy 3 — Separate Packages
Section titled “Strategy 3 — Separate Packages”For significantly different implementations:
MyGeneratorMyGenerator.Roslyn5can be reasonable.
Advantages:
- explicit;
- predictable;
- simple runtime behaviour.
Disadvantages:
- more packages;
- more maintenance;
- users must select correctly.
Strategy 4 — MSBuild-Selected Binary
Section titled “Strategy 4 — MSBuild-Selected Binary”Advanced packages can store binaries outside the automatically discovered analyser directory:
analysers/ roslyn4.8/ MyGenerator.dll
roslyn5.0/ MyGenerator.dll
buildTransitive/ MyGenerator.targetsThen a targets file can explicitly add exactly one:
<Analyser Include="..." />depending on an intentionally selected compatibility band.
Conceptually:
<ItemGroup Condition="'$(MyGeneratorRoslynBand)' == '4.8'"> <Analyser Include="$(MSBuildThisFileDirectory)..\analysers\roslyn4.8\MyGenerator.dll" /></ItemGroup>
<ItemGroup Condition="'$(MyGeneratorRoslynBand)' == '5.0'"> <Analyser Include="$(MSBuildThisFileDirectory)..\analysers\roslyn5.0\MyGenerator.dll" /></ItemGroup>The difficult question is:
How is
MyGeneratorRoslynBanddetermined reliably?
There is no general NuGet analyser-asset negotiation equivalent to normal TFM selection.
Do not use:
$(TargetFramework)for this.
It identifies the application runtime target, not the compiler host.
Using:
$(NETCoreSdkVersion)may work for a deliberately SDK-bound support model but must not be treated as universally equivalent to the active Roslyn host.
Design-time builds, Visual Studio, Rider, CI, and explicit compiler toolsets all need testing.
Multi-Targeting the Generator Is Not Automatic Selection
Section titled “Multi-Targeting the Generator Is Not Automatic Selection”This:
<TargetFrameworks> netstandard2.0;net8.0</TargetFrameworks>may produce two generator assemblies.
It does not mean NuGet will choose:
netstandard2.0 analyser for old compilernet8.0 analyser for new compilerfor you.
Building multiple binaries and selecting analyser assets are separate problems.
Recommended Rule
Section titled “Recommended Rule”Unless there is a compelling requirement:
Ship one
netstandard2.0analyser/generator binary compiled against the oldest Roslyn API version you need.
This remains the most robust distribution strategy.
17. Microsoft.CodeAnalysis.Analysers
Section titled “17. Microsoft.CodeAnalysis.Analysers”Do not confuse:
Microsoft.CodeAnalysis.CSharpwith:
Microsoft.CodeAnalysis.AnalysersThey serve different purposes.
Microsoft.CodeAnalysis.CSharp
Section titled “Microsoft.CodeAnalysis.CSharp”Provides Roslyn compiler APIs used to implement your analyser/generator.
Examples:
IIncrementalGeneratorDiagnosticAnalyserSyntaxNodeCompilationISymbolIOperationMicrosoft.CodeAnalysis.Analysers
Section titled “Microsoft.CodeAnalysis.Analysers”This is a meta-analyser package.
It analyses your analyser or source generator.
Its purpose is to detect incorrect or unsafe usage of Roslyn/compiler APIs.
It does not define your source-generator API baseline.
Current Package Version
Section titled “Current Package Version”As of August 2026, the current stable package is:
Microsoft.CodeAnalysis.Analysers 5.9.0Do not assume its version must match:
Microsoft.CodeAnalysis.CSharpFor example, it is perfectly reasonable to have:
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analysers" Version="5.9.0" PrivateAssets="all" />provided the meta-analyser version itself works with your build tooling.
These represent separate concerns:
Microsoft.CodeAnalysis.CSharp │ └── minimum Roslyn API used by your generator
Microsoft.CodeAnalysis.Analysers │ └── rules used while developing the generatorTransitive Availability
Section titled “Transitive Availability”Roslyn compiler packages already bring Microsoft.CodeAnalysis.Analysers into the dependency graph as development tooling.
You may nevertheless explicitly reference it when:
- using Central Package Management;
- deliberately pinning meta-analyser behaviour;
- keeping analyser tooling versions consistent across a repository;
- making the analyser-project configuration obvious.
PrivateAssets="all"
Section titled “PrivateAssets="all"”Roslyn development dependencies should normally use:
PrivateAssets="all"Example:
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynVersion)" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analysers" Version="$(RoslynAnalyserVersion)" PrivateAssets="all" />Your consumer should not gain ordinary runtime Roslyn package dependencies simply because it installed your source generator.
EnforceExtendedAnalyserRules
Section titled “EnforceExtendedAnalyserRules”Analyser and generator projects should normally enable:
<EnforceExtendedAnalyserRules> true</EnforceExtendedAnalyserRules>These rules detect implementation patterns that are particularly dangerous inside compiler-hosted code.
Do not immediately suppress an RSxxxx diagnostic.
First determine what compiler-host invariant the rule is protecting.
RS1035
Section titled “RS1035”RS1035 bans APIs considered inappropriate for analysers.
A common example is direct access to environment-dependent state.
The underlying principle is:
Analyser/generator execution should not silently depend on machine-global environment state.
Configuration should normally arrive through explicit compiler inputs such as:
- analyser config options;
- additional files;
- MSBuild properties exposed through analyser config;
- source code;
- metadata references.
RS2008
Section titled “RS2008”RS2008 relates to analyser diagnostic release tracking.
If your analyser publishes public diagnostic IDs, maintain release tracking files such as:
AnalyserReleases.Shipped.mdAnalyserReleases.Unshipped.mdThis helps detect accidental changes to diagnostic contracts.
Diagnostic IDs are effectively part of your public API.
Treat Diagnostic Descriptors as Public Contracts
Section titled “Treat Diagnostic Descriptors as Public Contracts”Changing:
ZS0001to:
ZS0017may break:
.editorconfig;- suppressions;
- CI configuration;
- documentation;
- consumer tooling.
Similarly, changing:
- default severity;
- category;
- diagnostic semantics;
should be treated as a compatibility decision.
18. Recommended Project Configuration
Section titled “18. Recommended Project Configuration”A broadly-compatible generator project might start with:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion> <Nullable>enable</Nullable>
<IsPackable>true</IsPackable> <IncludeBuildOutput>false</IncludeBuildOutput>
<EnforceExtendedAnalyserRules>true</EnforceExtendedAnalyserRules>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynVersion)" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analysers" Version="$(RoslynAnalyserVersion)" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(TargetPath)" Pack="true" PackagePath="analysers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>Then centrally define:
<PropertyGroup> <RoslynVersion>4.8.0</RoslynVersion> <RoslynAnalyserVersion>5.9.0</RoslynAnalyserVersion></PropertyGroup>The exact Roslyn baseline is a product-support decision.
ProjectReference During Development
Section titled “ProjectReference During Development”A consuming project can reference the generator as:
<ProjectReference Include="..\MyGenerator\MyGenerator.csproj" OutputItemType="Analyser" ReferenceOutputAssembly="false" />Separate Runtime Contracts From Compiler Tooling
Section titled “Separate Runtime Contracts From Compiler Tooling”Prefer:
MyLibrary.Abstractions │ ├── public attributes ├── runtime contracts └── shared public APIs
MyLibrary.SourceGenerators │ └── IIncrementalGenerator
MyLibrary.Analysers │ └── DiagnosticAnalyser
MyLibrary.CodeFixes │ └── CodeFixProviderover mixing runtime APIs and compiler tooling into one assembly.
This prevents Roslyn dependencies leaking into runtime package assets.
Roslyn Component Discovery
Section titled “Roslyn Component Discovery”The compiler host only loads a source generator, diagnostic analyser, or code fix provider when three conditions hold. Missing any one means the component is silently ignored, which is why “nothing shows up in Visual Studio” is usually a setup problem, not a code problem:
- The type is public. Non-public component types cannot be instantiated by Roslyn
(
PSGFR27). - The type is decorated. A generator needs
[Generator](PSGFR26), an analyser needs[DiagnosticAnalyzer](PSGFR25), and a code fix provider needs[ExportCodeFixProvider](PSGFR24). - The assembly is loaded as an analyser. In a package the component assembly must be packed
under
analyzers/dotnet/cs/; in a project reference it must be referenced withOutputItemType="Analyser". A normal library reference never surfaces a component to Roslyn.
A code fix provider also only appears when the diagnostic ID in FixableDiagnosticIds is actually
produced by an analyser that is loaded alongside it (PSGFR28). Visual Studio MEF-composes fix
providers when the analyser set loads, so after adding or updating a fixer assembly you must
restart Visual Studio or reload the project for the fixes to appear.
19. Extension Class Conventions
Section titled “19. Extension Class Conventions”Extension classes should form a coherent, discoverable shape so that “which type does this extend?” and “where does it live?” are answerable from the file path alone.
- Placement: under an
Extensionsfolder whose path mirrors the extended type’s namespace, e.g.Extensions/Microsoft/CodeAnalysis/...,Extensions/System/.... - Namespace: the extended type’s namespace, so the folder, namespace, and receiver all align
(
IDE0130enforces the folder↔namespace pairing). - Name:
{Receiver}Extensions(plural suffix), one receiver type per class. - Style: prefer C# 14
extension(Receiver receiver)blocks over classicpublic static T Method(this Receiver receiver, ...)methods. - Metadata:
[EditorBrowsable(EditorBrowsableState.Never)]on the class, so IntelliSense and documentation tooling treat them as framework plumbing rather than public API.
The framework’s analyzers enforce these rules:
| Rule | What it enforces |
|---|---|
PSGFR34 |
Prefer extension(...) blocks over classic this-parameter methods (gated on the language version supporting extension blocks). |
PSGFR35 |
Class name matches the extended type ({Receiver}Extensions). |
PSGFR36 |
Class lives in the extended type’s namespace. |
PSGFR37 |
One receiver type per class. |
PSGFR38 |
[EditorBrowsable(EditorBrowsableState.Never)] is present. |
The ReorganizeExtensionClassCodeFixProvider converts a class with many disparate extensions into the
coherent shape: it renames (PSGFR35), splits multi-receiver classes into per-type files (PSGFR37),
moves the class under Extensions/{ReceiverNamespace}/ and updates referencing files (PSGFR36), and the
ConvertToExtensionBlockCodeFixProvider converts classic methods to extension blocks (PSGFR34).
20. Review Checklist
Section titled “20. Review Checklist”Analyser
Section titled “Analyser”- Is this rule actually validation rather than generation?
- Am I using the narrowest appropriate analyser action?
- Does exact syntax matter?
- If not, should this use a symbol?
- If this is executable semantics, should this use
IOperation? - Is
EnableConcurrentExecution()enabled? - Is generated-code analysis explicitly configured?
- Are known framework/library symbols resolved once where appropriate?
- Are symbols compared semantically rather than by reference?
- Is whole-compilation analysis genuinely necessary?
- Could the diagnostic reasonably have a code fix?
- Are diagnostic IDs release-tracked?
- Is the analyser type
publicand decorated with[DiagnosticAnalyzer]? - Do the code fix’s
FixableDiagnosticIdsmatch an ID the analyser actually produces?
Incremental Generator
Section titled “Incremental Generator”- Uses
IIncrementalGenerator. - Uses
ForAttributeWithMetadataNamewhere appropriate. - Syntax predicates are extremely cheap.
- Semantic extraction happens once.
-
ISymbolnever enters persistent model state. -
Compilationdoes not propagate downstream. -
SemanticModeldoes not propagate downstream. -
IOperationdoes not propagate downstream. -
SyntaxTreedoes not propagate downstream. -
SyntaxNodeis removed as early as possible. -
Locationis removed as early as possible. - Pipeline models are immutable.
- Pipeline models have value equality.
- Collection members have sequence equality.
- Arrays are not relied upon for model equality.
-
ImmutableArray<T>is wrapped or explicitly compared where equality matters. - Transform callbacks are static where practical.
- Cancellation is honoured.
-
Collect()is only used where global knowledge is necessary. - Per-target output remains per-target.
-
Combine()does not unnecessarily broaden invalidation. -
CompilationProvideris not casually combined into output. -
WithComparer()represents real logical equality. - Hint names are deterministic.
- Generated text is deterministic.
- Constant source uses post-initialization output.
- Normal source validation lives in an analyser.
- Incremental caching behaviour has tests.
Packaging
Section titled “Packaging”- Generator/analyser binaries are packed as analyser assets.
- Compiler tooling is not accidentally shipped as runtime
liboutput. - Roslyn package dependencies are private.
- The Roslyn API baseline is intentional.
- The minimum supported Visual Studio version is documented.
- The minimum supported SDK/compiler environment is tested.
- Rider support is tested rather than inferred.
- Multi-targeting is not being mistaken for analyser asset selection.
- Multiple Roslyn binaries are not placed in the normal analyser folder expecting automatic selection.
- Any custom MSBuild analyser selection works during design-time builds.
-
Microsoft.CodeAnalysis.Analysersis enabled. -
EnforceExtendedAnalyserRulesis enabled. -
RSxxxxdiagnostics are investigated rather than reflexively suppressed.
Summary
Section titled “Summary”The shortest version of this guide is:
Analyser for validation; generator for generation. Syntax for syntax, symbols for declarations, operations for executable semantics. Use
ForAttributeWithMetadataNamewhenever possible.ISymbol,Compilation,SemanticModel, andIOperationdo not belong in incremental pipeline models. RemoveSyntaxNodeandLocationas soon as possible. Immutable does not mean equatable: arrays, lists, andImmutableArray<T>require deliberate sequence equality. UseEquatableArray<T>or an equivalent value-equatable collection abstraction. AvoidCollect()until global knowledge is genuinely required. Never combineCompilationProviderinto the pipeline merely because it is convenient. Compile against the oldest Roslyn API version containing the functionality you need. The consumer TFM does not determine analyser compatibility—the compiler host does. NuGet does not automatically choose between Roslyn-version-specific analyser binaries. Test incrementally and compatibility, not just generated source.