| created | 2026-08-29 | ||||
|---|---|---|---|---|---|
| updated | 2026-08-29 | ||||
| tags |
|
Practical guidance for writing Roslyn analysers and incremental source generators that remain fast, deterministic, cache-friendly, IDE-compatible, and safe to distribute.
- 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. Review Checklist
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.
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?
| 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 source
A useful shorthand is:
Analysers protect the contract. Generators implement the contract.
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.
| 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 |
The most common analyser design decision is choosing between:
- syntax;
- symbols;
- operations.
Does exact source spelling/structure matter?
│
├── Yes ──► Syntax
│
└── No
│
├── Is this a declaration?
│ └── Yes ──► Symbol
│
└── Is this executable behaviour?
└── Yes ──► Operation
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.
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.
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.
| 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.
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.
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.
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:
context.RegisterOperationAction(
AnalyzeInvocation,
OperationKind.Invocation
);over an action that sees every operation.
Prefer:
context.RegisterSyntaxNodeAction(
AnalyzeClass,
SyntaxKind.ClassDeclaration
);over scanning an entire SyntaxTree.
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.
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.
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 |
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
);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> 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 value
Think of every transformation as a cache checkpoint.
Prefer:
Roslyn Input
│
▼
Cheap discovery
│
▼
Semantic extraction
│
▼
Small equatable model
│
▼
Validation/transformation
│
▼
Generation model
│
▼
Source output
Do not do:
Roslyn Input
│
▼
Giant transform containing symbols + syntax + compilation
│
▼
Generate everything
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:
.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.
For non-trivial transformations:
.Select(static (value, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
return Transform(value, cancellationToken);
});Pass cancellation tokens into Roslyn APIs that accept them.
Prefer:
Syntax
↓
Symbol projection
↓
Type model
↓
Property models
↓
Generation model
↓
Output
over:
Syntax
↓
Do absolutely everything
↓
Output
More meaningful boundaries give Roslyn more opportunities to short-circuit downstream processing.
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:
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 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 IBaseSchemaThing
Collect() transforms:
IncrementalValuesProvider<T>into roughly:
IncrementalValueProvider<ImmutableArray<T>>This changes invalidation scope.
Before:
A ──► output A
B ──► output B
C ──► output C
After collection:
A ─┐
B ─┼──► [A,B,C] ──► output
C ─┘
Changing B changes the aggregate [A,B,C].
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.
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 source
Type Models ────────┤
│
└──► Collect()
│
▼
Global registry
Only the registry should pay the global invalidation cost.
Use Combine() when one output logically depends on two providers.
Example:
var generationInput =
typeModels.Combine(generatorOptions);That means:
type changed ───────┐
├──► generation invalidated
option changed ─────┘
This is correct if either input should regenerate the output.
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.
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.
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 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.
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.
For the same generator model:
input model
↓
identical generated source
Avoid:
- current timestamps;
- random GUIDs;
- process IDs;
- machine-specific paths;
- unordered dictionary output;
- machine environment variables;
- current culture affecting generation.
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.
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.
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
)
);
}
);Snapshot-testing generated source is not sufficient.
A generator can generate perfectly correct code while defeating almost all incremental caching.
Test both:
Correctness
+
Incrementally
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.
Create the generator driver with tracking enabled.
For example:
var driverOptions =
new GeneratorDriverOptions(
disabledOutputs:
IncrementalGeneratorOutputKind.None,
trackIncrementalGeneratorSteps:
true
);Inspect tracked output reasons such as:
New
Modified
Unchanged
Cached
Removed
The 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.
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.
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.
Do not interpret it as:
net8.0 application = Roslyn 4.8 analyser
That is incorrect.
A project may target:
<TargetFramework>net8.0</TargetFramework>while being compiled by:
Visual Studio 2026 / Roslyn 5.x
An analyser compiled against Roslyn 5.0 may therefore work.
The same net8.0 project opened in:
Visual Studio 2022 17.8 / Roslyn 4.8
cannot be assumed to load that Roslyn-5.0-based analyser.
The application TFM did not change.
The compiler host did.
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.
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 generation
However, SDK servicing and feature bands can contain later compiler versions.
Therefore do not use:
TargetFramework == net10.0
as 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 Version
matrix 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.
This area is frequently misunderstood.
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.dll
This is not a general-purpose:
Roslyn 4.8
Roslyn 4.14
Roslyn 5.0
selection mechanism.
Do not place multiple Roslyn-targeted implementations into the ordinary analyser folder and expect NuGet to automatically choose the correct one.
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.dll
Advantages:
- 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.
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.0
Document the minimum IDE/compiler requirement.
This is much easier for users to reason about than hidden runtime selection.
For significantly different implementations:
MyGenerator
MyGenerator.Roslyn5
can be reasonable.
Advantages:
- explicit;
- predictable;
- simple runtime behaviour.
Disadvantages:
- more packages;
- more maintenance;
- users must select correctly.
Advanced packages can store binaries outside the automatically discovered analyser directory:
analysers/
roslyn4.8/
MyGenerator.dll
roslyn5.0/
MyGenerator.dll
buildTransitive/
MyGenerator.targets
Then 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.
This:
<TargetFrameworks>
netstandard2.0;net8.0
</TargetFrameworks>may produce two generator assemblies.
It does not mean NuGet will choose:
netstandard2.0 analyser for old compiler
net8.0 analyser for new compiler
for you.
Building multiple binaries and selecting analyser assets are separate problems.
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.
Do not confuse:
Microsoft.CodeAnalysis.CSharp
with:
Microsoft.CodeAnalysis.Analysers
They serve different purposes.
Provides Roslyn compiler APIs used to implement your analyser/generator.
Examples:
IIncrementalGenerator
DiagnosticAnalyser
SyntaxNode
Compilation
ISymbol
IOperationThis 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.
As of August 2026, the current stable package is:
Microsoft.CodeAnalysis.Analysers 5.9.0
Do not assume its version must match:
Microsoft.CodeAnalysis.CSharp
For 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 generator
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.
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.
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 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 relates to analyser diagnostic release tracking.
If your analyser publishes public diagnostic IDs, maintain release tracking files such as:
AnalyserReleases.Shipped.md
AnalyserReleases.Unshipped.md
This helps detect accidental changes to diagnostic contracts.
Diagnostic IDs are effectively part of your public API.
Changing:
ZS0001
to:
ZS0017
may break:
.editorconfig;- suppressions;
- CI configuration;
- documentation;
- consumer tooling.
Similarly, changing:
- default severity;
- category;
- diagnostic semantics;
should be treated as a compatibility decision.
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.
A consuming project can reference the generator as:
<ProjectReference
Include="..\MyGenerator\MyGenerator.csproj"
OutputItemType="Analyser"
ReferenceOutputAssembly="false" />Prefer:
MyLibrary.Abstractions
│
├── public attributes
├── runtime contracts
└── shared public APIs
MyLibrary.SourceGenerators
│
└── IIncrementalGenerator
MyLibrary.Analysers
│
└── DiagnosticAnalyser
MyLibrary.CodeFixes
│
└── CodeFixProvider
over mixing runtime APIs and compiler tooling into one assembly.
This prevents Roslyn dependencies leaking into runtime package assets.
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.
- 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?
- 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.
- 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.
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.