# SourceGenerator Framework
> A typed framework for building incremental C# source generators.
A strongly typed framework for building, testing, and maintaining incremental C# source generators with Roslyn. Includes structured code generation, attribute data models, type libraries, and a step-cache test runner for verifying incremental behaviour.
- Repository: https://github.com/purview-dev/sourcegenerator-framework
- Package: https://www.nuget.org/packages/Purview.SourceGeneratorFramework
- Project page: https://purview.dev/projects/sourcegenerator-framework/
- Documentation: https://purview.dev/docs/sourcegenerator-framework/
- Full machine-readable content: https://purview.dev/projects/sourcegenerator-framework/llms-full.txt
# Getting Started
## Install
```bash
dotnet add package Purview.SourceGeneratorFramework
```
Reference the package from a Roslyn source generator project:
```xml
netstandard2.0
true
true
```
## Referencing a generator project
Roslyn must receive both a source-generator assembly and its framework runtime dependency as
analyzer inputs. Use an analyzer project reference:
```xml
```
The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns the generator
assembly without adding it to the consuming application's runtime references. By default the framework
returns a **merged, self-contained** generator from its intermediate output, so no loose
`Purview.SourceGeneratorFramework.dll` is needed and GASF-based packages stay self-contained. The
generator's own bin output stays unmerged, so its in-process test harness retains shared framework
type identity. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains supported but
is not required. Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the
unmerged assembly + loose framework DLL shape is required (see [Packaging.md](packaging/)).
### A component that references another component
A code-fix component can reference the generator component normally (`ProjectReference`,
`ReferenceOutputAssembly` not `false`) when it needs the generator's internal diagnostic identity.
The generator's **analyzer artifact** is the merged, self-contained assembly, while its **bin output**
stays unmerged; the framework assembly is copied beside that bin output and flows transitively
through `ProjectReference`, so the dependent component's bin folder is self-sufficient. The dependent
component's analyzer closure includes the referenced component's merged artifact — it is never
IL-merged a second time, which would duplicate its types. See
[Packaging.md](packaging/#a-component-that-references-another-component).
Declare each MSBuild property the generator reads with
``; `PSGF0003` fails the build when
the property is not compiler-visible.
### Referencing a generator from its test project
A test project can need the source-generator project in two different roles at the same time:
- as an analyzer, so the generator runs against the test project and its generated attributes and
other types can be used directly by test source files; and
- as a normal assembly reference, so the test code can name and instantiate the generator type
through `Purview.SourceGeneratorFramework.Testing`.
Add two project references with deliberately different metadata:
```xml
```
Do not put `OutputItemType="Analyzer"` on the normal reference. The Purview SDK automatically
uses `GetSourceGeneratorAnalyzerFiles` for the analyzer reference, which by default returns the
generator's **merged, self-contained** assembly from its intermediate output, so the generator loads
correctly with no extra runtime dependencies. The normal assembly reference resolves to the
generator's unmerged bin output, so the two roles do not interfere and the test compilation sees no
duplicate framework types.
Because the second reference is a normal assembly reference, the generator's Roslyn dependencies
also become visible to the test compilation. For a multi-target test project, build the generator
against the Roslyn version that supports its API usage and is compatible with the oldest test target.
This framework is built against Roslyn 5.0 (C# 14 / .NET 10 generation), which ships `net8.0` and
`net9.0` package assets, so a `.NET 8`–`.NET 10` test matrix still loads it. Compiler hosts must be
Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026). Do not centrally pin
`System.Collections.Immutable` to a newer runtime version merely to make the generator load.
## Write a generator
Implement `IIncrementalGenerator` and use the framework helpers to build a pipeline:
```csharp
using Microsoft.CodeAnalysis;
using Purview.SourceGeneratorFramework.Helpers;
using Purview.SourceGeneratorFramework.Models;
[Generator]
public sealed class MyGenerator : IIncrementalGenerator
{
static readonly TypeIdentity AttributeType = new("MyAttribute", "MyNamespace");
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context);
var targets = IncrementalPipeline.ForAttributeWithMetadataName(
context,
AttributeType,
static (ctx, ct) => ctx.TargetSymbol.Name
);
context.RegisterSourceOutput(
targets.CombineWithContext(contextProvider),
static (spc, pair) =>
{
var (name, generationContext) = pair;
var writer = generationContext.CreateCodeWriter();
writer.AutoGeneratedHeader();
writer.FileScopedNamespace("MyNamespace");
writer.Class(
name,
TypeDeclarationAccessibility.Public,
options => options with { IsStatic = true },
body => body.Comment("generated content")
);
spc.AddSource($"{name}.g.cs", writer.ToString());
}
);
}
}
```
See the [`SourceGeneratorFramework.ExampleGenerator`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator)
reference implementation for a complete end-to-end sample, and
[`SourceGeneratorFramework.ExampleGenerator.CodeFixers`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers)
for a companion code-fix sample.
## Test the generator
Reference the testing package and run the generator against a snippet of C#:
```bash
dotnet add package Purview.SourceGeneratorFramework.Testing
```
```csharp
using Purview.SourceGeneratorFramework.Testing;
public class MyGeneratorTests
{
[Test]
public async Task GeneratesExpectedSource()
{
var source = """
[MyNamespace.MyAttribute]
public partial class MyClass { }
""";
var runner = new SourceGeneratorTestRunner();
var result = await runner.RunAsync(source);
result.AssertNoCompilationErrors();
var generated = result.AssertSingleGeneratedSource();
}
}
```
Use the TUnit integration for ready-made test base classes and fluent assertions:
```bash
dotnet add package Purview.SourceGeneratorFramework.Testing.TUnit
```
## Next pages
- [Source Generator & Analyser Best Practices](guide/)
- [CodeWriter structured API reference](code-writer/)
- [Incremental Pipeline](incremental-pipeline/)
- [Testing](testing/)
- [Testing with TUnit](testing-tunit/)
- [Step-Cache Tests](step-cache-tests/)
- [Packaging](packaging/)
---
# 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
- [1. Core Principles](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#1-core-principles)
- [2. Analyser or Source Generator?](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#2-analyser-or-source-generator)
- [3. Choosing an Analyser Action](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#3-choosing-an-analyser-action)
- [4. Syntax vs Symbol vs Operation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#4-syntax-vs-symbol-vs-operation)
- [5. Analyser Best Practices](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#5-analyser-best-practices)
- [6. Incremental Generator Golden Rules](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#6-incremental-generator-golden-rules)
- [7. Pipeline Value Equality](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#7-pipeline-value-equality)
- [8. Designing the Incremental Pipeline](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#8-designing-the-incremental-pipeline)
- [9. Syntax Discovery](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#9-syntax-discovery)
- [10. `Collect`, `Combine`, and Invalidation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#10-collect-combine-and-invalidation)
- [11. Diagnostics](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#11-diagnostics)
- [12. Output Generation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#12-output-generation)
- [13. Testing Incrementally](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#13-testing-incrementally)
- [14. Roslyn Version Compatibility](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#14-roslyn-version-compatibility)
- [15. Visual Studio, .NET SDK, and Rider](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#15-visual-studio-net-sdk-and-rider)
- [16. Multi-Version Roslyn Packaging](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#16-multi-version-roslyn-packaging)
- [17. `Microsoft.CodeAnalysis.Analysers`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#17-microsoftcodeanalysisanalysers)
- [18. Recommended Project Configuration](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#18-recommended-project-configuration)
- [19. Extension Class Conventions](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#19-extension-class-conventions)
- [20. Review Checklist](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#20-review-checklist)
---
## 1. Core Principles
The most important rules are:
1. **Use an analyser to validate user code.**
2. **Use an incremental generator to generate code.**
3. **Use `ForAttributeWithMetadataName` for attribute-driven generators.**
4. **Remove Roslyn objects from the incremental pipeline as early as possible.**
5. **Every value crossing a pipeline boundary should have meaningful value equality.**
6. **Prefer many small incremental stages over one large transform.**
7. **Keep broad inputs such as `Compilation` away from downstream generation.**
8. **Generate deterministic output.**
9. **Compile against the oldest Roslyn API version you actually need.**
10. **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?
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
| 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:
```text
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.**
---
## 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
| 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
The most common analyser design decision is choosing between:
- syntax;
- symbols;
- operations.
## Quick Decision
```text
Does exact source spelling/structure matter?
│
├── Yes ──► Syntax
│
└── No
│
├── Is this a declaration?
│ └── Yes ──► Symbol
│
└── Is this executable behaviour?
└── Yes ──► Operation
```
---
## Syntax
Use syntax when the literal structure of the user's source matters.
Examples:
- is `partial` explicitly 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:
```csharp
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
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:
```csharp
context.RegisterSymbolAction(
AnalyzeNamedType,
SymbolKind.NamedType
);
```
When comparing symbols:
```csharp
SymbolEqualityComparer.Default.Equals(left, right)
```
should normally be used rather than reference equality.
---
## 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:
```csharp
context.RegisterOperationAction(
AnalyzeInvocation,
OperationKind.Invocation
);
```
Then:
```csharp
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:
```csharp
context.RegisterSyntaxNodeAction(
AnalyzeInvocation,
SyntaxKind.InvocationExpression
);
```
followed by:
```csharp
context.SemanticModel.GetSymbolInfo(...)
```
for every invocation.
---
## 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
## Enable Concurrent Execution
Analysers should normally enable concurrent execution:
```csharp
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
Do not leave generated-code handling implicit.
For most library contract analysers:
```csharp
context.ConfigureGeneratedCodeAnalysis(
GeneratedCodeAnalysisFlags.None
);
```
is appropriate.
Only inspect generated code if the analyser explicitly needs to.
---
## Resolve Known Types Once
If an analyser needs to repeatedly compare against known framework or library types, resolve them during compilation start.
```csharp
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
Prefer:
```csharp
context.RegisterOperationAction(
AnalyzeInvocation,
OperationKind.Invocation
);
```
over an action that sees every operation.
Prefer:
```csharp
context.RegisterSyntaxNodeAction(
AnalyzeClass,
SyntaxKind.ClassDeclaration
);
```
over scanning an entire `SyntaxTree`.
---
## 6. Incremental Generator Golden Rules
Implement:
```csharp
IIncrementalGenerator
```
rather than the legacy:
```csharp
ISourceGenerator
```
But 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
> **Pipeline values must be immutable and value-equatable.**
Roslyn needs to determine:
```text
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
## 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` | ❌ Avoid in models | Mutable and reference equality |
| `ImmutableArray` | ⚠ Wrap/compare explicitly | Immutable but not sequence-value-equatable for model equality |
| Mutable class | ❌ Avoid | Reference equality unless explicitly implemented |
---
## Good Model
```csharp
internal sealed record TypeModel(
string Namespace,
string Name,
string FullyQualifiedName,
Accessibility Accessibility,
EquatableArray Properties
);
internal sealed record PropertyModel(
string Name,
string FullyQualifiedTypeName,
bool IsNullable
);
```
---
## Bad Model
```csharp
internal sealed record TypeModel(
INamedTypeSymbol Symbol,
Compilation Compilation,
Location Location,
ImmutableArray Properties
);
```
Making the outer object a `record` does not magically make its members suitable for incremental equality.
---
## `ImmutableArray` Is Not Enough
`ImmutableArray` 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:
```csharp
EquatableArray
```
with sequence-based equality.
Conceptually:
```csharp
internal readonly struct EquatableArray
: IEquatable>
{
private readonly ImmutableArray _items;
public bool Equals(EquatableArray 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 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:
```text
same contents => equal pipeline value
```
---
## 8. Designing the Incremental Pipeline
Think of every transformation as a cache checkpoint.
Prefer:
```text
Roslyn Input
│
▼
Cheap discovery
│
▼
Semantic extraction
│
▼
Small equatable model
│
▼
Validation/transformation
│
▼
Generation model
│
▼
Source output
```
Do not do:
```text
Roslyn Input
│
▼
Giant transform containing symbols + syntax + compilation
│
▼
Generate everything
```
---
## Project Early
The semantic transform should usually be the boundary where Roslyn objects disappear.
Example:
```csharp
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
Prefer:
```csharp
.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
For non-trivial transformations:
```csharp
.Select(static (value, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
return Transform(value, cancellationToken);
});
```
Pass cancellation tokens into Roslyn APIs that accept them.
---
## Split Transformations
Prefer:
```text
Syntax
↓
Symbol projection
↓
Type model
↓
Property models
↓
Generation model
↓
Output
```
over:
```text
Syntax
↓
Do absolutely everything
↓
Output
```
More meaningful boundaries give Roslyn more opportunities to short-circuit downstream processing.
---
## 9. Syntax Discovery
## Prefer `ForAttributeWithMetadataName`
Attribute-driven generation should normally start with:
```csharp
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
Use:
```csharp
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:
```csharp
predicate:
static (node, _) =>
node is ClassDeclarationSyntax
{
AttributeLists.Count: > 0
}
```
Bad:
```csharp
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
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:
```csharp
[GenerateSchema]
partial class Customer
{
}
```
over:
```text
Generate everything somewhere downstream of IBaseSchemaThing
```
---
## 10. `Collect`, `Combine`, and Invalidation
## `Collect()`
`Collect()` transforms:
```csharp
IncrementalValuesProvider
```
into roughly:
```csharp
IncrementalValueProvider>
```
This changes invalidation scope.
Before:
```text
A ──► output A
B ──► output B
C ──► output C
```
After collection:
```text
A ─┐
B ─┼──► [A,B,C] ──► output
C ─┘
```
Changing `B` changes the aggregate `[A,B,C]`.
---
## Prefer Per-Item Output
Prefer:
```csharp
context.RegisterSourceOutput(
models,
static (context, model) =>
Emit(context, model)
);
```
instead of:
```csharp
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()`
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:
```text
┌──► Per-type source
Type Models ────────┤
│
└──► Collect()
│
▼
Global registry
```
Only the registry should pay the global invalidation cost.
---
## `Combine()`
Use `Combine()` when one output logically depends on two providers.
Example:
```csharp
var generationInput =
typeModels.Combine(generatorOptions);
```
That means:
```text
type changed ───────┐
├──► generation invalidated
option changed ─────┘
```
This is correct if either input should regenerate the output.
---
## Be Very Careful Combining `CompilationProvider`
This:
```csharp
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:
```csharp
var capabilities =
context.CompilationProvider
.Select(static (compilation, _) =>
new CompilationCapabilities(
HasRequiredType:
compilation.GetTypeByMetadataName(
"MyLibrary.RequiredType"
) is not null
)
);
```
Then:
```csharp
models.Combine(capabilities)
```
At least downstream equality can now short-circuit when the relevant capability did not change.
---
## `WithComparer()`
Roslyn provides:
```csharp
.WithComparer(...)
```
when default equality is insufficient.
Example:
```csharp
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:
```csharp
record Model(
INamedTypeSymbol Symbol,
Compilation Compilation
);
```
followed by an elaborate comparer.
The better solution is usually to redesign `Model`.
---
## 11. Diagnostics
## 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
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
`GeneratorResult.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:
```csharp
var diagnostic = ReportableDiagnostic.Create(
MissingOverride,
isBlocking: false, // report the error, but keep generating
symbol,
symbol.Name,
"Execute"
);
return GeneratorResult.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
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:
```csharp
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
## Output Must Be Deterministic
For the same generator model:
```text
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.
---
## Deterministic Hint Names
Good:
```csharp
context.AddSource(
$"{model.HintName}.g.cs",
source
);
```
Bad:
```csharp
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
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:
```csharp
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
Use:
```csharp
RegisterPostInitializationOutput
```
for source that is constant regardless of the user's compilation.
Examples:
- marker attributes;
- fixed helper attributes;
- static support types.
Example:
```csharp
context.RegisterPostInitializationOutput(
static context =>
{
context.AddSource(
"GenerateAttribute.g.cs",
SourceText.From(
"""
//
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
Snapshot-testing generated source is not sufficient.
A generator can generate perfectly correct code while defeating almost all incremental caching.
Test both:
```text
Correctness
+
Incrementally
```
---
## 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
Create the generator driver with tracking enabled.
For example:
```csharp
var driverOptions =
new GeneratorDriverOptions(
disabledOutputs:
IncrementalGeneratorOutputKind.None,
trackIncrementalGeneratorSteps:
true
);
```
Inspect tracked output reasons such as:
```text
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.
### 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.
```csharp
var result = await new SourceGeneratorTestRunner().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
[Step-Cache-Tests.md](../step-cache-tests/) for the full walkthrough and the canonical
`StepCacheTests.cs` sample in the ExampleGenerator unit tests.
---
## 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:
```xml
...
```
does not determine analyser compatibility.
Analyser/generator code executes inside a compiler/IDE host.
---
## 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 against `Microsoft.CodeAnalysis` 5.x, so compiler
> hosts that load them must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026 18.0). The
> testing packages multi-target `net8.0`–`net10.0`; Roslyn 5.x ships `net8.0`/`net9.0` package assets,
> so those test targets still load the test runner.
Do not interpret it as:
```text
net8.0 application = Roslyn 4.8 analyser
```
That is incorrect.
---
## Example
A project may target:
```xml
net8.0
```
while being compiled by:
```text
Visual Studio 2026 / Roslyn 5.x
```
An analyser compiled against Roslyn 5.0 may therefore work.
The same `net8.0` project opened in:
```text
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.
---
## 15. Visual Studio, .NET SDK, and Rider
## 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
The .NET SDK contains a compiler toolchain.
Broad release alignment is:
```text
.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:
```text
TargetFramework == net10.0
```
as proof that a particular Roslyn API is available to your analyser.
Likewise:
```xml
$(TargetFramework)
```
should not be used to choose the analyser binary.
The relevant variable is the compiler host.
---
## Rider
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:
```text
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:
1. choose a conservative Roslyn baseline;
2. test the oldest Rider version you support;
3. test `dotnet build`;
4. test Rider design-time generation;
5. test generated-source navigation;
6. test analysers and code fixes where applicable.
Build-time compiler compatibility and Rider IDE integration should be tested independently.
---
## 16. Multi-Version Roslyn Packaging
This area is frequently misunderstood.
## NuGet Analyser Assets Are Not Normal TFM Assets
Normal runtime/library assets support selection such as:
```text
lib/net8.0/
lib/net9.0/
lib/net10.0/
```
Analyser assets conventionally live under:
```text
analysers/
dotnet/
cs/
MyGenerator.dll
```
This is not a general-purpose:
```text
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.
---
## Strategy 1 — One Conservative Binary
### Recommended Default
Compile against the oldest Roslyn version required by your implementation.
For example:
```xml
```
Package:
```text
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.
---
## 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:
```text
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.
---
## Strategy 3 — Separate Packages
For significantly different implementations:
```text
MyGenerator
MyGenerator.Roslyn5
```
can be reasonable.
Advantages:
- explicit;
- predictable;
- simple runtime behaviour.
Disadvantages:
- more packages;
- more maintenance;
- users must select correctly.
---
## Strategy 4 — MSBuild-Selected Binary
Advanced packages can store binaries outside the automatically discovered analyser directory:
```text
analysers/
roslyn4.8/
MyGenerator.dll
roslyn5.0/
MyGenerator.dll
buildTransitive/
MyGenerator.targets
```
Then a targets file can explicitly add exactly one:
```xml
```
depending on an intentionally selected compatibility band.
Conceptually:
```xml
```
The difficult question is:
> How is `MyGeneratorRoslynBand` determined reliably?
There is no general NuGet analyser-asset negotiation equivalent to normal TFM selection.
Do **not** use:
```xml
$(TargetFramework)
```
for this.
It identifies the application runtime target, not the compiler host.
Using:
```xml
$(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
This:
```xml
netstandard2.0;net8.0
```
may produce two generator assemblies.
It does **not** mean NuGet will choose:
```text
netstandard2.0 analyser for old compiler
net8.0 analyser for new compiler
```
for you.
Building multiple binaries and selecting analyser assets are separate problems.
---
## Recommended Rule
Unless there is a compelling requirement:
> **Ship one `netstandard2.0` analyser/generator binary compiled against the oldest Roslyn API version you need.**
This remains the most robust distribution strategy.
---
## 17. `Microsoft.CodeAnalysis.Analysers`
Do not confuse:
```text
Microsoft.CodeAnalysis.CSharp
```
with:
```text
Microsoft.CodeAnalysis.Analysers
```
They serve different purposes.
---
## `Microsoft.CodeAnalysis.CSharp`
Provides Roslyn compiler APIs used to implement your analyser/generator.
Examples:
```csharp
IIncrementalGenerator
DiagnosticAnalyser
SyntaxNode
Compilation
ISymbol
IOperation
```
---
## `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
As of August 2026, the current stable package is:
```text
Microsoft.CodeAnalysis.Analysers 5.9.0
```
Do not assume its version must match:
```text
Microsoft.CodeAnalysis.CSharp
```
For example, it is perfectly reasonable to have:
```xml
```
provided the meta-analyser version itself works with your build tooling.
These represent separate concerns:
```text
Microsoft.CodeAnalysis.CSharp
│
└── minimum Roslyn API used by your generator
Microsoft.CodeAnalysis.Analysers
│
└── rules used while developing the generator
```
---
## 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"`
Roslyn development dependencies should normally use:
```xml
PrivateAssets="all"
```
Example:
```xml
```
Your consumer should not gain ordinary runtime Roslyn package dependencies simply because it installed your source generator.
---
## `EnforceExtendedAnalyserRules`
Analyser and generator projects should normally enable:
```xml
true
```
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
`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
`RS2008` relates to analyser diagnostic release tracking.
If your analyser publishes public diagnostic IDs, maintain release tracking files such as:
```text
AnalyserReleases.Shipped.md
AnalyserReleases.Unshipped.md
```
This helps detect accidental changes to diagnostic contracts.
Diagnostic IDs are effectively part of your public API.
---
## Treat Diagnostic Descriptors as Public Contracts
Changing:
```text
ZS0001
```
to:
```text
ZS0017
```
may 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
A broadly-compatible generator project might start with:
```xml
netstandard2.0
latest
enable
true
false
true
true
```
Then centrally define:
```xml
4.8.0
5.9.0
```
The exact Roslyn baseline is a product-support decision.
---
## ProjectReference During Development
A consuming project can reference the generator as:
```xml
```
---
## Separate Runtime Contracts From Compiler Tooling
Prefer:
```text
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.
---
## 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:
1. **The type is public.** Non-public component types cannot be instantiated by Roslyn
(`PSGFR27`).
2. **The type is decorated.** A generator needs `[Generator]` (`PSGFR26`), an analyser needs
`[DiagnosticAnalyzer]` (`PSGFR25`), and a code fix provider needs `[ExportCodeFixProvider]`
(`PSGFR24`).
3. **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 with
`OutputItemType="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
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 `Extensions` folder 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
(`IDE0130` enforces the folder↔namespace pairing).
- **Name**: `{Receiver}Extensions` (plural suffix), one receiver type per class.
- **Style**: prefer C# 14 `extension(Receiver receiver)` blocks over classic
`public 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
## 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 `public` and decorated with `[DiagnosticAnalyzer]`?
- [ ] Do the code fix's `FixableDiagnosticIds` match an ID the analyser actually produces?
---
## Incremental Generator
- [ ] Uses `IIncrementalGenerator`.
- [ ] Uses `ForAttributeWithMetadataName` where appropriate.
- [ ] Syntax predicates are extremely cheap.
- [ ] Semantic extraction happens once.
- [ ] `ISymbol` never enters persistent model state.
- [ ] `Compilation` does not propagate downstream.
- [ ] `SemanticModel` does not propagate downstream.
- [ ] `IOperation` does not propagate downstream.
- [ ] `SyntaxTree` does not propagate downstream.
- [ ] `SyntaxNode` is removed as early as possible.
- [ ] `Location` is 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` 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.
- [ ] `CompilationProvider` is 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
- [ ] Generator/analyser binaries are packed as analyser assets.
- [ ] Compiler tooling is not accidentally shipped as runtime `lib` output.
- [ ] 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.Analysers` is enabled.
- [ ] `EnforceExtendedAnalyserRules` is enabled.
- [ ] `RSxxxx` diagnostics are investigated rather than reflexively suppressed.
---
## 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 `ForAttributeWithMetadataName` whenever possible.**
> **`ISymbol`, `Compilation`, `SemanticModel`, and `IOperation` do not belong in incremental pipeline models.**
> **Remove `SyntaxNode` and `Location` as soon as possible.**
> **Immutable does not mean equatable: arrays, lists, and `ImmutableArray` require deliberate sequence equality.**
> **Use `EquatableArray` or an equivalent value-equatable collection abstraction.**
> **Avoid `Collect()` until global knowledge is genuinely required.**
> **Never combine `CompilationProvider` into 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.**
---
# CodeWriter
`CodeWriter` is the structured, allocation-conscious writer used to build generated C# source. Instead
of concatenating strings or writing raw text, generators describe *what* to emit — declarations,
statements, scopes — and the writer handles indentation, blank-line separation, generated attributes,
and deterministic layout.
This page uses the current best-practice API: bare semantic names (`Class`, `Method`, `Property`), the
minimal-parameter overloads with an optional `configure` callback, and structured statements
(`Return`, `MethodCall`, `Assignment`) instead of raw text.
## Construction and scope validation
A `CodeWriter` is created with `GenerationSettings` and defaults to the **production** configuration:
scope validation is off, so no opening stack traces are captured and `ToString()` materializes partial
output even while a scope is open. `GenerationContext.CreateCodeWriter()` inherits this via
`GenerationSettings.ValidateCodeWriterScopes`, which defaults to `false`.
Testing flips the default so a generator that forgets a `using` or leaves a block open fails fast
instead of silently emitting malformed code. With validation enabled, `ToString()` throws
`CodeWriterScopeValidationException` listing every open scope, its header, and the stack trace captured
when it was opened:
- `CodeWriterFactory.ForTests()` and `CodeWriter.CreateTestWriter()` enable validation by default
(`throwOnUnclosedScopes: true`).
- `SourceGeneratorTestOptions.ValidateCodeWriterScopes` defaults to `true`, so the test runner enables
it for the generators under test.
- Pass `throwOnUnclosedScopes: false` explicitly when a test intentionally materializes partial output.
Scope tracking has a real cost — every scope open captures a `StackTrace` and allocates a per-scope
record — which is why production leaves it off (see [Performance.md](../performance/)).
## Primitives
Use the raw primitives for low-level text that has no structured equivalent:
```csharp
writer.Write("partial"); // no trailing line feed
writer.Line("// generated"); // line feed appended
writer.Append("text"); // Write alias
writer.AppendLine("text"); // Line alias
writer.Comment("Explains the next member.");
writer.Indent(); // increase indentation
writer.NewLine();
```
`Write`/`Line`/`Append`/`AppendLine` are the only methods that retain a verb prefix: everything
semantic drops it because the receiver is already a writer.
## Declarations
Each declaration writer has:
- a **minimal overload** taking name/type/accessibility plus an optional `configure` callback
(`options => options with { ... }`); and
- a **scope form** (`...Scope`) returning a `BlockScope` for `using` when you need fine-grained control.
Type declarations can also be written **without a body**, terminated with a semicolon instead of an empty
block — useful for marker types, primary-constructor records, and host-kit stubs:
```csharp
// public sealed partial class TestingHostKit;
writer.Class(
new TypeDeclarationOptions("TestingHostKit", TypeDeclarationAccessibility.Public)
{
IsPartial = true,
Attributes = [new(HostKitAttribute) { Arguments = [new(true, "GenerateOptions", true)] }],
}
);
// public record class Point(int X, int Y);
writer.RecordClass(
new TypeDeclarationOptions("Point") { PrimaryConstructorParameters = [new("X", intType), new("Y", intType)] }
);
// writer.Class("C"); // public sealed partial class C;
```
The semicolon-terminated form is valid for `Class`, `Struct`, `RecordClass`, `RecordStruct`, and `Interface`
(primary-constructor parameters, base types, and `where` constraints are still written). Enums and delegates
always require a body / self-terminate.
```csharp
writer.Class(
"OrderService",
TypeDeclarationAccessibility.Public,
options => options with { IsSealed = true, IsPartial = false },
body =>
{
body.Field("_total", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Private);
body.Constructor(
"OrderService",
TypeDeclarationAccessibility.Public,
options => options with
{
Parameters = [new("total", TypeIdentity.Create().AsTypeReference())],
},
constructorBody => constructorBody.Assignment("_total", "total")
);
body.Property(
"Total",
TypeIdentity.Create().AsTypeReference(),
TypeDeclarationAccessibility.Public
);
}
);
```
The same pattern applies to `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum` (+ `EnumField`),
`Type` (kind-driven), `Delegate`, `AttributeClass`, `Method`/`PartialMethod`/`MethodExpression`,
`Property`/`PropertyExpression`, `Indexer`, `Field`, and `Operator`.
### Scope forms
```csharp
using (writer.ClassScope("OrderService", TypeDeclarationAccessibility.Public))
using (writer.MethodScope("Apply", TypeLibrary.System.Void, TypeDeclarationAccessibility.Public))
{
writer.MethodCall("Validate");
}
```
Scope forms are ideal when a declaration spans multiple calls, loops, or conditional content. The
`using` statement is mandatory — the closing token and indentation are written on dispose, and the
`DiscardedCodeWriterScopeAnalyzer` (PSGFR17) flags scope returns that are dropped.
### C# 14 extension-member blocks
`ExtensionBlockScope`/`ExtensionBlock` emit C# 14 `extension(...)` blocks (Roslyn 5.0 or later), for
generators that need to attach members to a receiver type (note that extension members compile to static
accessor methods such as `get_X`, not CLR properties):
```csharp
using (writer.ExtensionBlockScope(
new TypeIdentity("PurviewTypeLibrary", "Purview.SourceGeneratorFramework")
.Nested("System")
.Nested("Diagnostics")
.AsTypeReference()))
{
writer.Property(
"Activity",
TypeReference.Create(),
TypeDeclarationAccessibility.Public,
options => options with { IsStatic = true, ExpressionBody = "Activity" });
}
// extension(global::Purview.SourceGeneratorFramework.PurviewTypeLibrary.System.Diagnostics)
// {
// public static global::Purview.SourceGeneratorFramework.TypeIdentity Activity => Activity;
// }
```
The receiver must be a plain named type; composed references (arrays, pointers, nullable annotations,
type parameters and `dynamic`) and the null literal are rejected. Use the callback form
`writer.ExtensionBlock(receiver, body => ...)` for a complete block in one call.
### Enums
Generated enums are emitted with the `[Embedded]` marker attribute by default — the same default as
`AttributeClass` — so the enum is embedded into each consuming assembly rather than leaking as a
reference to the generator's type surface. Opt a specific enum out with
`IncludeEmbeddedAttribute = false` in the `configure` callback:
```csharp
writer.Enum("ServiceLifetime", TypeDeclarationAccessibility.Public,
options => options with { IncludeEmbeddedAttribute = false });
```
Fields are separated by a blank line, matching the spacing applied to other members, so XML summaries
and attributes stay readable:
```csharp
writer.Enum("Status", TypeDeclarationAccessibility.Public,
fields:
[
new("None", 0),
new("Ready", 1) { XmlSummary = ["The service is ready."] },
]);
// [global::Microsoft.CodeAnalysis.Embedded]
// [global::System.Runtime.CompilerServices.CompilerGenerated]
// [global::System.CodeDom.Compiler.GeneratedCode("TestGenerator", "1.0.0")]
// public enum Status
// {
// None = 0,
//
// ///
// /// The service is ready.
// ///
// Ready = 1,
// }
```
`XmlSummary` is always written over multiple lines, so single-line summaries are emitted as a
`` block:
```csharp
writer.XmlSummary("Gets the value.");
// ///
// /// Gets the value.
// ///
```
### Field spacing
Consecutive `Field` declarations are emitted without a blank line between them when they carry no
decoration. A blank line is inserted before a field when it (or the preceding field) has a generated or
user attribute, an XML summary, or a comment — so generated attributes keep consecutive fields readable:
```csharp
writer.Field("_first", TypeReference.Create());
writer.Field("_second", TypeReference.Create());
// [global::System.Runtime.CompilerServices.CompilerGenerated]
// [global::System.CodeDom.Compiler.GeneratedCode("TestGenerator", "1.0.0")]
// private int _first;
//
// [global::System.Runtime.CompilerServices.CompilerGenerated]
// [global::System.CodeDom.Compiler.GeneratedCode("TestGenerator", "1.0.0")]
// private int _second;
```
## Statements
Emit executable statements through the structured statement methods rather than raw `Line`:
```csharp
writer.MethodCall("Process", "item"); // Process(item);
writer.AwaitedMethodCall("SaveAsync", "cancellationToken"); // await SaveAsync(cancellationToken);
writer.MethodCallOn("variable", "Process", "item"); // variable.Process(item);
writer.AwaitedMethodCallOn("service", "LoadAsync", "token"); // await service.LoadAsync(token);
writer.Return("value"); // return value;
writer.Throw(TypeIdentity.Create(), "Failed."); // throw new ...;
writer.Assignment("_total", "value"); // _total = value;
writer.Assignment("var hostKit", expression => expression.New("HostKit", "onBuilt")); // var hostKit = new HostKit(onBuilt);
writer.IfBlock("value is null", body => body.Return("null"));
writer.IfBlock("value is null", body => body.Return("null"))
.ElseIf("value is 0", body => body.Return("zero"))
.Else(body => body.Return("value"));
writer.Foreach("var item in items", body => body.MethodCallOn("item", "Process"));
```
`MethodCall`/`AwaitedMethodCall` write a call without a receiver — `Process(item);` or
`await SaveAsync(token);`. Use `MethodCallOn`/`AwaitedMethodCallOn` (or the `receiver` parameter on the
`IEnumerable` overloads) for a call on a variable, including generic arguments:
```csharp
writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeReference.Create()]);
// factory.Create(x);
```
When a statement or declaration must embed a runtime or user-supplied string — for example a
regular-expression pattern or error message — emit it through the `StringLiteral()` extension rather
than wrapping it in quotes by hand. It returns a quoted, escaped C# string literal:
```csharp
body.Field("regex", regexType, TypeDeclarationAccessibility.Private,
options => options with { IsStatic = true, Initializer = $"new({pattern.StringLiteral()})" });
// pattern = ^[\w\-.]+$ => new("^[\\w\\-.]+$")
```
A **chained** invocation — where the result of each call is the receiver of the next, and a postfix is
applied to the final result — is expressed with `MethodCallChain`/`AwaitedMethodCallChain`. The chain
is written as an expression (no terminating semicolon), so it composes as the value of an
`Assignment`/`Return` expression callback:
```csharp
writer.Assignment(
"var hostKitOptions",
expression => expression.MethodCallChain(
"builder.Configuration.GetSection",
[$"{name}.SectionName"],
chain => chain.Method("Get", genericArguments: [optionsType]).Postfix(" ?? new()")));
// var hostKitOptions = builder.Configuration.GetSection("x.SectionName").Get() ?? new();
```
- `rootMethod` may include the receiver (e.g. `builder.Configuration.GetSection`); each subsequent
`.Method(...)` call implicitly uses the previous result as its receiver.
- `genericArguments` provides the `<...>` type arguments for a segment.
A chain that starts on a receiver with generic arguments uses `genericArguments` on the root:
```csharp
writer.Assignment("var optionsBuilder", expression =>
expression.MethodCallChain(
"builder.Services.AddOptions",
[],
chain => chain.Method("BindConfiguration", ["options.SectionName"]),
genericArguments: [optionsType]));
// var optionsBuilder = builder.Services.AddOptions().BindConfiguration("options.SectionName");
```
- `Postfix(expression)` appends a trailing expression such as `?? new()` or `!`.
### Object creation
`New` writes an object-creation expression — `new Type(...)` or a target-typed `new(...)` — without a
trailing semicolon, so it composes as the value of an `Assignment`/`Return` expression callback:
```csharp
writer.Assignment("var hostKit", expression =>
expression.New("HostKit", "onBuilt", "onConfigured"));
// var hostKit = new HostKit(onBuilt, onConfigured);
writer.Assignment("HostKit hostKit", expression =>
expression.New(["onBuilt", "onConfigured"]));
// HostKit hostKit = new(onBuilt, onConfigured);
```
`New` accepts a verbatim type name, a `TypeReference`, structured `MethodCallArgumentOptions` (preserving
`ref`/`out`/`in` modifiers and named arguments), or no type at all. Use `expression.New()` for `new()`. The
no-type form emits a target-typed `new(...)` expression, which is valid only where the target type is known
(an assignment to a typed local, field, property, parameter, or a `return` statement).
`ObjectCreationOptions` supports the same no-type construction at statement level via its argument-only
constructor:
```csharp
writer.Assignment(
context.HostKit.HostKitType,
"hostKit",
new ObjectCreationOptions("onBuilt", "onConfigured"));
// HostKitType hostKit = new(onBuilt, onConfigured);
```
A null-conditional receiver — `onBuilt?.Invoke(this, builder);` — is written with the `nullConditional`
argument on the structured `MethodCallOn`/`AwaitedMethodCallOn` overloads, which also accept
`genericArguments`:
```csharp
writer.MethodCallOn("onBuilt", "Invoke", ["this", "builder"], nullConditional: true);
// onBuilt?.Invoke(this, builder);
writer.MethodCallOn("builder.Services", "AddOptions", genericArguments: [optionsType]);
// builder.Services.AddOptions();
```
### Conditional statements
`IfBlock`/`IfBlockScope` write an `if` block. `ElseIf`/`ElseIfScope` chain an `else if` block after an
`if` or another `else if`, and `Else`/`ElseScope` close the chain with an `else` block. The methods
return the writer, so branches can be chained fluently:
```csharp
writer
.IfBlock("value is null", body => body.Return("null"))
.ElseIf("value is 0", body => body.Return("zero"))
.Else(body => body.Return("value"));
```
Emits:
```csharp
if (value is null)
{
return null;
}
else if (value is 0)
{
return zero;
}
else
{
return value;
}
```
`IfElse(condition, ifBody, elseBody)` is the compact two-branch form. The scope forms
`IfBlockScope`, `ElseIfScope`, and `ElseScope` write the header and return the body scope for
content that spans multiple calls.
### Conditional compilation blocks
`HashDefines`/`HashDefinesScope` write a `#if`/`#endif` block with both directives at **column zero**.
The body keeps the surrounding indentation — file-level directives and their content stay at column
zero, while class members inside the block stay at the same indent as their siblings:
```csharp
using (writer.HashDefinesScope("!EXCLUDE_PURVIEW_TELEMETRY_LOGGING"))
{
writer.FileScopedNamespace("Example");
writer.Enum("Mode", TypeDeclarationAccessibility.Public, fields: [new("Default", 0)]);
}
// Equivalent action form:
writer.HashDefines("NET", body => body.Line("// NET only"));
```
Emits:
```csharp
#if !EXCLUDE_PURVIEW_TELEMETRY_LOGGING
namespace Example;
...
#endif
```
At file level these blocks are self-spacing: a blank line is ensured before the `#if` and after the
`#endif`, so directive sections remain separated without explicit `NewLine()` calls.
`HashElse()` writes the `#else` directive at column zero between the two bodies:
```csharp
using (writer.HashDefinesScope("NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE"))
{
writer.Property("name", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Public,
options => options with { HasSetter = true, IncludeGeneratedAttributes = false });
writer.HashElse();
writer.Property("name", TypeIdentity.Create().MakeNullable(writer), TypeDeclarationAccessibility.Public,
options => options with { HasSetter = true, IncludeGeneratedAttributes = false });
}
```
Emits:
```csharp
#if NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE
public string name { get; set; }
#else
public string? name { get; set; }
#endif
```
`EmptyScope()` returns a no-op scope so a block can be wrapped only when a guard requires it:
```csharp
using var scope = wrapInExcludeLoggingGuard
? writer.EmptyScope()
: writer.HashDefinesScope("EXCLUDE_PURVIEW_TELEMETRY_LOGGING");
```
### Pragma warning suppression
`PragmaDisable` writes a single `#pragma warning disable` directive at column zero for one or more
warning codes. At file level it is self-spacing (blank lines are ensured around the directive):
```csharp
writer.PragmaDisable("CS8625", "CS0618");
// #pragma warning disable CS8625 CS0618
```
For a scoped disable that restores the warnings when the scope is disposed, use `OpenPragmasScope`:
```csharp
using (writer.OpenPragmasScope("CS0618"))
{
writer.Line("ObsoleteCall();");
}
// #pragma warning disable CS0618
// ObsoleteCall();
// #pragma warning restore CS0618
```
The full header pattern — nullable directive, conditional `#nullable enable`, and a disabled warning —
can be expressed entirely through the structured APIs (the file-level directives are self-spacing, so
no explicit `NewLine()` calls are needed):
```csharp
writer.AutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable);
writer.HashDefines("!NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE", hashWriter => hashWriter.Line("#nullable enable"));
writer.PragmaDisable("CS8625");
writer.FileScopedNamespace("Purview.Telemetry");
```
Emits:
```csharp
//
// This code was generated by ExampleGenerator (version 1.0.0).
// Changes to this file will be lost when the source generator runs again.
#if !NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE
#nullable enable
#endif
#pragma warning disable CS8625
namespace Purview.Telemetry;
```
The generator version in the header and the `GeneratedCode` attribute comes from the
`GenerationSettings` used to create the writer. When settings are created via
`GenerationSettings.Create()`, the full assembly informational version is used, so any
pre-release suffix (such as `-alpha`) and build metadata (such as `+commit-hash`) are preserved rather
than being reduced to the numeric assembly version.
### Conditional compilation returns
`NetConditionalReturn` writes a `return` for an interpolated string using the best invariant-culture
API on each target framework, guarded by `#if NET`:
```csharp
writer.Method(
"Format",
TypeIdentity.Create().AsTypeReference(),
TypeDeclarationAccessibility.Public,
null,
body => body.NetConditionalReturn("Value: {_value}")
);
```
Emits:
```csharp
#if NET
return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $"Value: {_value}");
#else
return global::System.FormattableString.Invariant($"Value: {_value}");
#endif
```
## Default accessibility
`CodeWriter` applies a default accessibility for each member kind when a declaration does not specify
one. Set the defaults on `GenerationSettings` (to apply across a generation) or on the writer itself
(to override per writer). Each value is `null`-able, so setting a kind back to `null` omits the
modifier entirely.
Partial methods are the one exception to method defaulting: `PartialMethod(...)` and any method
declaration with `IsPartial = true` omit the accessibility modifier when none is provided, even if
`DefaultMethodAccessibility` is still `Public`.
| Setting | Default |
|---|---|
| `DefaultTypeAccessibility` | `Public` |
| `DefaultPropertyAccessibility` | `Public` |
| `DefaultPropertyGetterAccessibility` | `Public` |
| `DefaultPropertySetterAccessibility` | `Public` |
| `DefaultFieldAccessibility` | `Private` |
| `DefaultMethodAccessibility` | `Public` |
| `DefaultConstructorAccessibility` | `Public` |
| `DefaultIndexerAccessibility` | `Public` |
| `DefaultOperatorAccessibility` | `Public` |
```csharp
var writer = generationContext.CreateCodeWriter();
writer.Field("_total", TypeReference.Create()); // private int _total; (DefaultFieldAccessibility)
writer.Property("Total", TypeReference.Create()); // public decimal Total { get; }
```
An explicit accessibility always wins over the default:
```csharp
writer.Property("Total", TypeReference.Create(), TypeDeclarationAccessibility.Internal);
// internal decimal Total { get; }
```
Accessor (getter/setter) defaults are emitted only when they are **more restrictive** than the
property's own accessibility — C# forbids an accessor modifier that is equal to or more permissive
than the property (CS0273). With the public defaults, a public property keeps bare `{ get; set; }`:
```csharp
writer.DefaultPropertySetterAccessibility = TypeDeclarationAccessibility.Private;
writer.Property("Name", TypeReference.Create(), TypeDeclarationAccessibility.Public,
options => options with { HasSetter = true });
// public string Name { get; private set; }
```
## Guidance
- Prefer the minimal overloads with a `configure` callback over constructing `*DeclarationOptions`
values manually — the `PreferMinimalCodeWriterOverloadAnalyzer` (PSGFR20) flags the verbose form.
- Prefer structured declarations and statements over raw text — `PreferStructuredCodeWriterApiAnalyzer`
(PSGFR18) and `PreferStructuredCodeWriterStatementAnalyzer` (PSGFR19) flag raw emission.
- Prefer `IfBlock`/`ElseIf`/`Else` over generic block methods for conditional content — the
`PreferStructuredCodeWriterIfBlockAnalyzer` (PSGFR23) flags `OpenBlockScope`/`OpenBlock` headers that
write an `if`, `else if`, or `else` statement, and its code fix rewrites them.
- Always consume scope-returning methods with `using` (PSGFR17).
- Never embed a `CodeWriter` in a string. The XML block-writing methods (`XmlCode`, `XmlSummary`,
`XmlCodeBlock`, ...) return the `CodeWriter`, so interpolating or concatenating them implicitly calls
`ToString()` and dumps the writer's possibly-incomplete buffer — `CodeWriterInStringContextAnalyzer`
(PSGFR29) flags it. For inline XML tags in documentation text, use the static helpers instead:
`XmlCommentWriter.XmlInlineCode("value")` → `value`, or `XmlInlineCodeBlock(...)` for a ``
block; `writer.XmlCode(...)` writes to the buffer and returns the writer, it does not produce a string.
When the writer is genuinely complete, call `ToString()` explicitly.
- Never write an open generic as a type. A `TypeIdentity` with a generic arity but no type arguments renders
a placeholder such as `List<>` or `List<,>`, which is invalid C# in a type position (base type, return
type, parameter, property, ...). `CodeWriter` rejects it when it is emitted as a type — construct it with
`MakeGeneric(...)` first. An arity mismatch (`MakeGeneric` supplying the wrong number of arguments) is
also rejected, so the mismatch surfaces as a clear exception rather than corrupted generated code.
- Keep every value emitted through the structured API so layout stays deterministic and the analyzers
can guide callers back to the best practice.
## Generators embedded in another package
When a generator built with this framework is embedded into a different NuGet package (rather than
shipped as its own package), the outer package must make the framework's compiler-visible properties
available to its consumers, because build assets from `Purview.SourceGeneratorFramework` are not
automatically copied into the outer package.
Ship a `.props` file with the outer package that declares each property and its
`CompilerVisibleProperty` entry, and pack it under `buildTransitive/` using the outer package's ID so
NuGet imports it for consumers:
```xml
false
Throws when generated source is materialized while CodeWriter scopes remain undisposed.
```
```xml
```
The framework's public compiler-visible properties are
`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes`,
`PurviewSourceGeneratorFrameworkEnableLogging`,
`PurviewSourceGeneratorFrameworkLoggingSessionId`, and
`PurviewSourceGeneratorFrameworkLanguageVersion`. See [Packaging.md](../packaging/) for the full
self-contained generator packaging guidance.
## Samples
The [`SourceGeneratorFramework.ExampleGenerator`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator)
reference implementation demonstrates these APIs end-to-end, including the `CodeWriterSampleGenerator`,
which compiles a best-practice sample class for every `[GenerateCodeWriterSample]` target.
---
# TypeLibraryGenerator
`TypeLibraryGenerator` removes the boilerplate of hand-writing a type library — the static class that
exposes the `TypeIdentity` and `TypeReference` values your generator needs to reference framework,
reference, and self-generated types. It is part of `Purview.SourceGeneratorFramework.Generators` and runs
automatically for any spec annotated with `[GenerateTypeLibrary]`.
## What it generates
For a small declarative spec, the generator emits a **self-contained** `public static partial class` (the
generated type library) whose nested `public static partial` classes mirror the namespaces of the declared
members. Every class — the root and each nested namespace class — exposes a `public const string Namespace`
and the leaf classes expose the members as `public static readonly` fields:
The generated type library is always `public` in the component's own assembly so a hand-written
partial can merge with it (TLB0015 enforces the matching `public static partial` declaration) and so
in-repo consumers such as code fixers and sibling assemblies can compile against it. When the
component is packaged, the merge tool internalizes every framework-owned type in the shipped analyzer
(including the `TypeIdentity`/`TypeReference`/`PurviewTypeLibrary` members this library exposes), so
nothing leaks out of the package; see [Packaging.md](../packaging/).
Author documentation is copied into the generated library. A `cref` that targets a framework type is
rendered as inline code (`TypeReference`) while it is copied, because the generated file's
namespace and using set differ from the author's source and an unresolvable cref would produce
`CS1574`. `PSGFR40` reports the same pattern in the editor and its code fix applies the same
rewrite.
```csharp
namespace MyGenerator;
public static partial class TypeLibrary
{
public const string Namespace = "MyGenerator";
public static partial class System
{
public const string Namespace = "System";
public static partial class Diagnostics
{
public const string Namespace = "System.Diagnostics";
public static readonly TypeIdentity Activity = new("Activity", "System.Diagnostics");
}
}
}
```
No `extension(...)` blocks are emitted. The generated types are `public static partial` so you can expand
them with your own methods in a separate partial file — the extension partial must be declared
`public static partial` in the **same** namespace as the generated type. The generated type is emitted in
the namespace given by the `Namespace` argument, or the **global namespace** when it is omitted, so a
partial declared inside your project namespace will not merge with it (it silently shadows the generated
type instead). `TLB0014` and `TLB0015` flag these mistakes. The framework's own library is
`PurviewTypeLibrary` (the two never collide, and composed members reference it directly).
The generated class **inherits the full `PurviewTypeLibrary` shape**: every nested namespace class and
member of the framework library (`System.String`, `System.Collections.Generic.List`,
`Microsoft.Extensions.DependencyInjection.IServiceCollection`, …) is present, emitted as an alias
reference to the framework value so arity and generic construction are preserved exactly. Your
`[TypeRef]` members are merged into the matching nested classes; a member with the same name as an
inherited member in the same nested class shadows it.
## The DSL
```csharp
namespace Purview.Telemetry.SourceGenerator;
[GenerateTypeLibrary(
ClassName = "TelemetryTypeLibrary", // generated type name (default: "TypeLibrary")
Namespace = "Purview.Telemetry.SourceGenerator")] // generated type's namespace (default: global)
static partial class TypeLibraryModel // spec — separate from the generated type
{
// Namespace-only: type name defaults to the member name → nested class .Purview.Telemetry:
[TypeRef("Purview.Telemetry")]
static readonly TypeIdentity ActivitySourceGenerationAttribute = default;
// typeof(...) form → nested class .System.Diagnostics:
[TypeRef(typeof(global::System.Diagnostics.Activity))]
static readonly TypeIdentity Activity = default;
// Explicit type + namespace → nested class .Microsoft.Extensions.Logging:
[TypeRef("ILogger", "Microsoft.Extensions.Logging")]
static readonly TypeIdentity ILogger = default;
}
```
The spec class must be declared `static partial` and should use a distinct name from the generated
class (`ClassName`, default `TypeLibrary`) — `TLB0012`/`TLB0013` flag a collision, with a code fix
that renames the spec (e.g. `TypeLibrary` → `TypeLibraryGenerator`).
### Marker attributes
| Attribute | Targets | Purpose |
| --- | --- | --- |
| `[GenerateTypeLibrary]` | class | Marks the spec; configures `ClassName`, `Namespace`. |
| `[TypeRef]` | field | Declares one `TypeIdentity` or `TypeReference` member. |
`[TypeRef]` offers three declaration forms:
| Form | Type name | Namespace |
| --- | --- | --- |
| `[TypeRef("Purview.Telemetry")]` | the member name | the string argument |
| `[TypeRef(typeof(Activity))]` | the symbol name | inferred from the type (or the named `Namespace`/positional argument) |
| `[TypeRef("Activity", "System.Diagnostics")]` | the name string | the second argument |
All forms accept an optional generic `arity` argument (`[TypeRef("Test", 1)]`, or the third argument of
the explicit form, e.g. `[TypeRef("List`1", "System.Collections.Generic")]`); `typeof(...)` derives the
arity from the symbol automatically.
Every form also accepts an optional `includeInGetTypes` argument that follows `arity` —
`[TypeRef("Test", 0, true)]` or `[TypeRef("ILogger", "Microsoft.Extensions.Logging", -1, true)]` — or as
the named argument `includeInGetTypes: true`. It controls whether the member is included in the
namespace's generated `GetTypes()` call (see below).
### Member accessibility
Members are inert declarations read by the generator at compile time:
- **Plain `TypeIdentity` members are markers** and must be declared `private` (an unmodified
`static readonly` field is private). The generator produces `new("Name", "Namespace"[, arity])`.
- **`TypeReference` members, and `TypeIdentity` members that declare an initializer** (value members),
must be declared `internal`; their initializer expression becomes the generated value.
The analyzer reports `TLB0008` for invalid accessibility and `TLB0009` when a value member has no
initializer; both are fixable (`Make private`/`Make internal` for `TLB0008`). Marker members without an
explicit `= default` initializer are flagged by `TLB0010` (with an `Add '= default'` fix). The generator
also emits a small partial of the spec class that references the marker fields, so the compiler's
unused-member analysis does not flag them — the spec must therefore be declared `partial`
(`TLB0011`, with a `Make partial` fix).
### Value members (composed references)
A `TypeReference` field — or a `TypeIdentity` field with a real initializer — declares a composed value
(generic constructions with arguments, arrays, nullable). The initializer may reference other
`[TypeRef]` members by name and the framework `PurviewTypeLibrary`:
```csharp
[TypeRef(typeof(ActivityLink))]
static readonly TypeIdentity ActivityLink = default;
[TypeRef("System.Diagnostics")]
internal static readonly TypeReference ActivityLinkArray = new TypeReference(ActivityLink).MakeArray();
[TypeRef("System.Collections.Generic")]
internal static readonly TypeReference ActivityTagIEnumerable =
global::Purview.SourceGeneratorFramework.PurviewTypeLibrary.System.Collections.Generic.IEnumerable.MakeGeneric(
global::Purview.SourceGeneratorFramework.PurviewTypeLibrary.System.String);
```
The generated nested class then exposes
`TypeLibrary.System.Diagnostics.ActivityLinkArray` and
`TypeLibrary.System.Collections.Generic.ActivityTagIEnumerable` as `TypeReference` fields. Use the
fully-qualified `PurviewTypeLibrary.System...` form in initializers so the spec compiles regardless of
local `TypeLibrary` names.
### Enum values
Use `[EnumValue]` to declare the members of an enum type that the generator emits. The enum type itself
must be declared by a sibling `[TypeRef]` marker in the same namespace (`TLB0017` flags a missing
declaration). Each value is a private marker field whose name becomes the enum member name:
```csharp
[GenerateTypeLibrary(ClassName = "TypeLibrary", Namespace = "MyGenerator")]
static partial class TypeLibraryModel
{
[TypeRef("LikeC4Severity", "Aspire.Hosting.AspireC4", GenerateFullNameConst = true)]
static readonly TypeIdentity LikeC4Severity = default;
// Explicit enum name + namespace form.
[EnumValue("LikeC4Severity", "Aspire.Hosting.AspireC4", 0)]
static readonly TypeIdentity Inherit = default;
// Single fully-qualified enum type name form.
[EnumValue("Aspire.Hosting.AspireC4.LikeC4Severity", 3)]
static readonly TypeIdentity Warning = default;
}
```
`[EnumValue]` offers the same two declaration forms as `[TypeRef]` — an explicit `enumName` + `namespace`
+ `value`, or a single fully-qualified enum type name + `value` — plus an optional `aliases` argument
(array of alternate names used when matching). The value is a numeric literal of any enum underlying
type — `byte`, `sbyte`, `short`, `ushort`, `int` (default), `uint`, `long` or `ulong` — written as
`(byte)5`, `5`, `5L`, `5UL`, and so on. The literal's type drives the generated
`EnumValueDefinition.UnderlyingType`, and `EnumValueDefinition.Value` is stored as a `decimal` so every
underlying type (including `ulong.MaxValue`) is represented exactly.
#### Declaring values inline on the enum's `[TypeRef]` field
When the enum type is declared by a `[TypeRef]` in the same spec, `[EnumValue]` can be applied directly on
that `[TypeRef]` field — the enum type is inferred from the sibling `[TypeRef]` and each attribute's first
argument is the enum member name. This works even when the enum type is not available as a compile-time
type, and multiple `[EnumValue]` attributes may be declared on the field (`AllowMultiple`):
```csharp
[GenerateTypeLibrary(ClassName = "TypeLibrary", Namespace = "MyGenerator")]
static partial class TypeLibraryModel
{
[TypeRef("Aspire.Hosting.AspireC4.LikeC4Severity", GenerateFullNameConst = true)]
[EnumValue("Inherit", 0)]
[EnumValue("Warning", 3)]
static readonly TypeIdentity LikeC4Severity = default;
}
```
This is equivalent to declaring the enum type with a standalone `[TypeRef]` marker and each value with a
separate `[EnumValue]` marker field; the two styles can be mixed for the same enum.
The generator emits a nested `public static partial class {EnumName}Values` alongside the enum's `TypeIdentity`:
```csharp
public static partial class AspireC4
{
public static readonly TypeIdentity LikeC4Severity = new("LikeC4Severity", "Aspire.Hosting.AspireC4");
public const string LikeC4SeverityFullName = "Aspire.Hosting.AspireC4.LikeC4Severity"; // GenerateFullNameConst
public static partial class LikeC4SeverityValues
{
public const string InheritFullName = LikeC4SeverityFullName + "." + "Inherit"; // when the enum's [TypeRef] sets GenerateFullNameConst
public static readonly EnumValueDefinition Inherit = new(LikeC4Severity, "Inherit", 0);
public static EnumValueDefinition Get(string name)
{
if (Inherit.Matches(name))
return Inherit;
return EnumValueDefinition.Empty;
}
}
}
```
`EnumValueDefinition` exposes `Name`, `Value` (a `decimal` that represents every underlying type
exactly), `UnderlyingType`, `FullName` (`Namespace.Enum.Member`), `Aliases`, and a `Matches(string)`
matcher that accepts the member name, its full name, a trailing `Enum.Member` form, or any alias. Use the
generated values to emit the enum itself via `writer.Enum(...)` and to reference members as attribute
defaults:
```csharp
writer.Enum("LikeC4Severity", TypeDeclarationAccessibility.Public, options => options with { IsPartial = false },
ew =>
{
ew.EnumField(Inherit.Name, Inherit.Value);
ew.EnumField(Warning.Name, Warning.Value);
});
// Attribute default referencing a value:
new("severity", TypeLibrary.Aspire.Hosting.AspireC4.LikeC4Severity)
{
DefaultValue = TypeLibrary.Aspire.Hosting.AspireC4.LikeC4SeverityValues.Warning.FullName,
};
```
Enum value marker fields must be declared `private static readonly` (`TLB0008`), with an optional explicit
`= default` (`TLB0010`), and use a `TypeIdentity` or `EnumValueDefinition` field type (`TLB0016`). Duplicate
member names in a group are `TLB0018`; duplicate numeric values are flagged as `TLB0019`.
#### Declaring all values of an available enum type
When the enum type is available at compile time, use `[EnumValues(typeof(...))]` to declare every member in
one marker instead of listing individual `[EnumValue]` markers. The enum type must still be declared by a
sibling `[TypeRef]` marker in the same namespace (`TLB0017`), and the referenced type must actually be an
enum (`TLB0020`):
```csharp
[GenerateTypeLibrary(ClassName = "TypeLibrary", Namespace = "MyGenerator")]
static partial class TypeLibraryModel
{
[TypeRef("ServiceLifetime", "MyGenerator", GenerateFullNameConst = true)]
static readonly TypeIdentity ServiceLifetime = default;
// Enum type is available, so one marker declares Singleton, Scoped, Transient, ...
[EnumValues(typeof(ServiceLifetime))]
static readonly TypeIdentity ServiceLifetimeValues = default;
}
```
The generator emits the same `{EnumName}Values` nested class that the equivalent `[EnumValue]` markers
produce: a `EnumValueDefinition` field per enum member (with its numeric value and underlying type taken
from the enum declaration, and XML documentation copied from each member), the per-value `{Member}FullName`
constants when the enum's `[TypeRef]` sets `GenerateFullNameConst`, and the `Get(string)` matcher. The
marker field's name is arbitrary and ignored — the member names come from the enum itself. The two
declaration styles can be combined for the same enum; an `[EnumValues]` marker that collides with an
existing member or another bulk marker is flagged (`TLB0018`).
### Using full-name constants as attribute-data model targets
A `[TypeRef]` member declared with `GenerateFullNameConst` produces a `public const string {Member}FullName`
holding the member's fully-qualified type name (`"Aspire.Hosting.AspireC4.SeverityAttribute"`). That constant
can be used as the `[Generate]` target of an attribute-data model instead of a `typeof(...)` value:
```csharp
[TypeRef("Aspire.Hosting.AspireC4", GenerateFullNameConst = true)]
static readonly TypeIdentity SeverityAttribute = default;
[Generate(TypeLibrary.Aspire.Hosting.AspireC4.SeverityAttributeFullName)]
public readonly partial record struct SeverityAttributeData(
[Argument(IsEnum = true, Name = "severity", DefaultValue = "Inherit")] string Severity,
[Property(IsEnum = true, DefaultValue = "Inherit")] string Level
);
```
Because the `TypeLibrary` class is emitted through `TypeLibraryGenerator`'s main pipeline, its constants are
**not** present in the compilation that `AttributeDataModelGenerator`'s `ForAttributeWithMetadataName` pipeline
sees (only post-initialization output is shared between generators in a single pass). `AttributeDataModelGenerator`
therefore reassembles the target from the argument's member-access expression — guarded so the root identifier
must match a `[GenerateTypeLibrary]` spec's `ClassName` — and resolves it against the compilation. The target
attribute itself (here `SeverityAttribute`) is typically declared by the consumer's own generator as
post-initialization output, which is resolvable.
For `[Argument]`/`[Property]` members marked `IsEnum = true`, a `DefaultValue` supplied as a **bare member name**
(for example `"Inherit"`) is expanded to the fully-qualified `"{EnumFullName}.{Member}"` form
(`"Aspire.Hosting.AspireC4.LikeC4Severity.Inherit"`) using the enum type of the target attribute's matching
constructor parameter (for `[Argument]`) or property (for `[Property]`). Fully-qualified defaults and defaults
whose enum type cannot be resolved are emitted unchanged.
### Including types in `GetTypes()`
Mark a member with `includeInGetTypes: true` to include it in its namespace's generated `GetTypes()`
method, which returns an `ImmutableArray` of the included members:
```csharp
[TypeRef("ILogger", "Microsoft.Extensions.Logging", includeInGetTypes: true)]
static readonly TypeIdentity ILogger = default;
```
The nested class then exposes:
```csharp
public static ImmutableArray GetTypes() => [ ILogger ];
```
Plain `TypeIdentity` members and value members both participate. A namespace with no included members
emits no `GetTypes()` method.
### XML documentation
XML documentation on the spec class and each `[TypeRef]` field is copied onto the corresponding
generated class and member.
## Requirements
The generated output uses C# 14 features (nested partial types, collection expressions), so consumers
must compile with a C# 14 compiler (.NET SDK 10 / Roslyn 5.0 or later).
## Disabling
Set the MSBuild property `DisablePurviewTypeLibraryGenerator` to `true` to disable the generator.
## Validation
`TypeLibraryValidationAnalyzer` reports `TLB0001`–`TLB0021` for invalid specs, enum value members, and
type library partial extensions (non-static class, member type that is not `TypeIdentity`/`TypeReference`,
unresolvable type/namespace, duplicate members, invalid class name, invalid namespace, invalid member
accessibility, value members without an initializer, marker members without an explicit `= default`, a spec
that is not declared `partial`, and a spec class whose name collides with the generated type library class —
`TLB0012` when they share a namespace, `TLB0013` when they do not). It also reports `TLB0014` when a
source partial class shares the generated library's name but is declared in a different namespace
(so it will not merge), `TLB0015` when a same-namespace partial does not match the generated
`public static partial` modifiers, `TLB0016`–`TLB0019` for invalid `[EnumValue]`/`[EnumValues]` members
(member type, a missing sibling enum declaration, a non-enum type, and duplicate members/values), and
`TLB0020` when an `[EnumValues]` marker references a type that is not an enum. `TLB0002`, `TLB0008`,
`TLB0010`, `TLB0011`, `TLB0012`, and `TLB0013` have code fixes.
The generator carries these same diagnostics on its `GeneratorResult` and gates generation on
`ShouldProcess`. Most are blocking (`IsBlocking: true`) and stop generation, but the non-blocking rules —
`TLB0010` (marker without `= default`) and `TLB0013` (warning) — allow generation to continue, so a spec
with those issues still produces the type library. See
[`GeneratorResult` diagnostics that don't stop generation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework/Sdk/README.md#diagnostics-that-dont-stop-generation).
---
# Attribute Data Models
`AttributeDataModelGenerator` generates `readonly record struct` parser models for .NET attributes.
Instead of hand-writing `FromAttributeData` methods for every attribute you inspect in a source
generator, declare a `readonly partial record struct` with `[Generate]` and the generator fills in the
`Empty` sentinel, `FromAttributeData` overloads, and property extraction logic.
The generator is implemented in `Purview.SourceGeneratorFramework.Generators` and ships inside the
`Purview.SourceGeneratorFramework` package under `analyzers/dotnet/cs/`, so it runs automatically
when you reference the package.
## Marker attributes
The generator emits marker attributes into your compilation:
| Attribute | Purpose |
| --- | --- |
| `[Generate(Type targetAttribute)]` | Placed on a `readonly partial record struct` to opt into generation. |
| `[Generate(string targetAttribute)]` | Resolves the attribute by fully-qualified name. Use when the attribute type is not available in the generator's compilation (e.g. `LengthAttribute` in .NET 8+ or a self-generated attribute). |
| `[Property]` | A record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). When combined with `[Argument]` on the same parameter, the named argument is read first. |
| `[Property(string name)]` | Explicit named property source. |
| `[Property(..., DefaultValue = ...)]` | Fallback value when the named property is not present. |
| `[Argument]` | Populated from a constructor argument by parameter name. |
| `[Argument(int index)]` | Constructor argument by parameter index. |
| `[Argument(string name)]` | Constructor argument by parameter name. |
| `[Argument(..., DefaultValue = ...)]` | Fallback when the constructor argument is not present. |
| `[NestedModel]` | Populated by recursively calling `FromAttributeData` on a nested generated model. |
| `[Exclude]` | Skips auto-discovery for this parameter. |
| `[GenericTypeArgument]` | Populated from a generic type argument of the attribute class. |
| `[GenericTypeArgument(int index)]` | Generic type argument by position. |
| `[GenericTypeArgument(string name)]` | Generic type argument by type parameter name. |
## Manual mapping
```csharp
using Microsoft.CodeAnalysis;
using Purview.SourceGeneratorFramework.Generators;
using System.ComponentModel.DataAnnotations;
namespace MySourceGenerator.Models;
[Generate(typeof(RequiredAttribute))]
public readonly partial record struct RequiredAttributeData(
bool AllowEmptyStrings
);
```
Generated output:
```csharp
readonly record struct RequiredAttributeData(bool Exists, bool AllowEmptyStrings)
{
public static readonly RequiredAttributeData Empty = new(false, default(bool));
public static RequiredAttributeData FromAttributeData(ImmutableArray attributes)
{
// ...
}
public static RequiredAttributeData FromAttributeData(AttributeData attributeData)
{
if (!TargetAttribute.Equals(attributeData.AttributeClass))
return Empty;
attributeData.TryGetNamedArgument("AllowEmptyStrings", out var allowEmptyStrings);
return new(true, allowEmptyStrings);
}
}
```
## String target names
When the attribute type is not referenced in the generator project, pass the fully-qualified name as
a string. This is useful for attributes newer than the generator's target framework (e.g.
`LengthAttribute` in .NET 8+) or attributes that are generated by the same generator:
```csharp
[Generate("System.ComponentModel.DataAnnotations.RequiredAttribute")]
public readonly partial record struct RequiredAttributeData(
bool AllowEmptyStrings
);
```
A plain type name (`"RequiredAttribute"`) can also be used, which matches an attribute in the global
namespace or in any namespace. `AutoDiscover` requires the real `Type` overload because it must
inspect the attribute's constructors and properties.
## Constructor arguments
```csharp
[Generate(typeof(LengthAttribute))]
public readonly partial record struct LengthAttributeData(
[Argument(0)] int MinimumLength,
[Argument(1)] int MaximumLength
);
```
Or by constructor parameter name:
```csharp
[Generate(typeof(StringLengthAttribute))]
public readonly partial record struct StringLengthAttributeData(
[Argument("maximumLength", DefaultValue = 2147483647)] int MaximumLength,
int MinimumLength
);
```
## Constructor and named arguments on the same property
A property can declare both sources:
```csharp
[Generate(typeof(GenerateServiceAttribute))]
public readonly partial record struct GenerateServiceAttributeData(
[Argument("lifetime", IsEnum = true, DefaultValue = "…ServiceLifetime.Singleton")] string? Lifetime,
[Argument("name")] [Property] string? Name
);
```
The named argument is read first. A named argument assigns the property/field *after* the constructor
runs, so it is the effective value whenever a caller supplies both — mirroring the attribute instance's
own assignment order. Reading it first also prevents an omitted optional constructor parameter's default
from shadowing an explicitly set property:
```csharp
[GenerateService(Name = "Billing")] // reads "Billing"
[GenerateService(ServiceLifetime.Scoped, "Billing")] // reads "Billing"
```
:::caution
Before this rule, the constructor argument was read first, so
`[GenerateService(Name = "Billing")]` resolved to the `name` parameter's default (`null`) and the
explicitly set property was silently ignored.
:::
## Nested models
Any property whose type is itself annotated with `[Generate]` can be populated as a nested model.
This is useful for shared base attribute data, such as `ValidationAttribute` in
`System.ComponentModel.DataAnnotations`:
```csharp
[Generate(typeof(ValidationAttribute), MatchByInheritance = true)]
public readonly partial record struct ValidationAttributeData(
[Property] string? ErrorMessage,
[Property] string? ErrorMessageResourceName,
[Property] ITypeSymbol? ErrorMessageResourceType
);
[Generate(typeof(RequiredAttribute))]
public readonly partial record struct RequiredAttributeData(
bool AllowEmptyStrings,
[NestedModel] ValidationAttributeData ValidationAttribute
);
```
Because `ValidationAttributeData` uses `MatchByInheritance = true`, it matches any attribute that
derives from `ValidationAttribute`, including `RequiredAttribute`.
## Generic type arguments
If the attribute class is generic, a record parameter can be populated from the attribute's type
argument:
```csharp
[Generate(typeof(MyGenericAttribute<>))]
public readonly partial record struct MyGenericAttributeData(
[GenericTypeArgument] T Value
);
```
Use `[GenericTypeArgument(0)]` or `[GenericTypeArgument("TValue")]` to disambiguate when the
attribute has multiple type parameters.
## Auto-discovery
For simple attributes you can let the generator discover all constructor parameters and public named
properties automatically:
```csharp
[Generate(typeof(RequiredAttribute), AutoDiscover = true)]
public readonly partial record struct RequiredAttributeData;
```
This generates the same `RequiredAttributeData` as the manual example above. Nested models are not
auto-discovered; declare them explicitly if needed.
## Default values
`DefaultValue` provides a runtime fallback when the attribute does not contain the requested property
or argument. The `Empty` sentinel always uses `default(T)` for every property (including an `Exists`
field set to `false`):
```csharp
[Generate(typeof(HostKitAttribute))]
public readonly partial record struct HostKitAttributeData(
[Argument("name", DefaultValue = "MyApp")] string Name,
[Argument("generateOptions", DefaultValue = true)] bool GenerateOptions
);
```
## Type library integration
A `[TypeRef]` member declared with `GenerateFullNameConst` produces a `public const string
{Member}FullName`, which can be used as the `[Generate]` target of an attribute-data model instead of
a `typeof(...)` value — see [Type-Library.md](../type-library/#using-full-name-constants-as-attribute-data-model-targets)
for the full example.
Because the `TypeLibrary` class is emitted through `TypeLibraryGenerator`'s main pipeline, its
constants are **not** present in the compilation that `AttributeDataModelGenerator`'s
`ForAttributeWithMetadataName` pipeline sees (only post-initialization output is shared between
generators in a single pass). `AttributeDataModelGenerator` therefore reassembles the target from the
argument's member-access expression — guarded so the root identifier must match a
`[GenerateTypeLibrary]` spec's `ClassName` — and resolves it against the compilation.
For `[Argument]`/`[Property]` members marked `IsEnum = true`, a `DefaultValue` supplied as a **bare
member name** (for example `"Inherit"`) is expanded to the fully-qualified
`"{EnumFullName}.{Member}"` form using the enum type of the target attribute's matching constructor
parameter (for `[Argument]`) or property (for `[Property]`). Fully-qualified defaults and defaults
whose enum type cannot be resolved are emitted unchanged.
## License
This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project.
---
# Incremental Pipeline
`IncrementalPipeline` provides extension methods for composing `IncrementalValueProvider` and
`IncrementalValuesProvider` pipelines — attribute-based discovery, generation-context creation,
disable-property checks, and thin source-output registration. It is designed around the golden rule
from the [best-practices guide](../guide/): **pipeline values must be immutable and value-equatable.**
## GenerationContext
`GenerationContext` is a base execution-services context that carries:
- the Roslyn `Compilation`;
- immutable generator `GenerationSettings`;
- an optional `ISourceGenLogger`; and
- a factory for independently owned `CodeWriter` instances.
```csharp
var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context);
```
Create a fresh writer through the generation context so it inherits the configuration:
```csharp
var writer = generationContext.CreateCodeWriter();
```
`CreateCodeWriter()` returns a new, independently owned instance on every call. The writer is not
stored on `GenerationContext`; keep it scoped to the source-output operation that owns the generated
source.
### Custom generation contexts
Custom contexts do not need to accept or read build properties themselves:
```csharp
public sealed class MyGenerationContext : GenerationContext
{
public MyGenerationContext(
Compilation compilation,
GenerationSettings settings,
ISourceGenLogger? logger)
: base(compilation, settings, logger)
{
}
}
```
Use the ordinary context-provider overload. The framework combines the compiler-visible property
with the compilation and supplies the resulting immutable settings to the custom context factory:
```csharp
var contextProvider = IncrementalPipeline.GenerationContextValueProvider(
context,
nameof(MyGenerator),
"1.0.0",
factory: static (compilation, settings, logger, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
return new MyGenerationContext(compilation, settings, logger);
},
disablePropertyName: "MyGenerator_Disable"
);
```
The provider resolves scope validation, generator disabling, and test logging from analyzer-config
properties before invoking the factory. The supplied logger is created internally only when logging
is enabled and a sink is registered for that run.
## Keep CodeWriter out of incremental contexts
Treat `GenerationContext` values as cached incremental-pipeline state and each `CodeWriter` as
mutable, output-scoped execution state. Create the writer inside the registered source-output
callback, after the incremental cache boundary. Creating it in the callback and passing it to
emitter/helper methods called from that same callback is the intended pattern; the only thing that
is forbidden is persisting the writer in pipeline state, where Roslyn caches it:
```csharp
IncrementalPipeline.RegisterSourceOutput(
context,
targets,
contextProvider,
static (spc, target, generationContext) =>
{
var writer = generationContext.CreateCodeWriter();
EmitTarget(generationContext, writer, target);
spc.AddSource($"{target.Name}.g.cs", writer.ToString());
}
);
```
This separation is intentional:
- Roslyn caches the complete value published by an incremental provider. It does not provide a way
to exclude one property of that value from caching.
- `CodeWriter` is mutable. Caching one can retain previously written source when the context is
reused for another output or generator run.
- Source-output callbacks may process independent targets concurrently. Sharing a writer can mix
their output and introduce data races.
- A fresh writer gives each generated source independent scope tracking and deterministic ownership.
These rules also apply to custom contexts: **never add or assign a `CodeWriter` property or field on
a class derived from `GenerationContext`**. A custom context is still produced by an incremental
provider and cached as one complete value. Store only compilation-derived services and immutable
configuration there, and call `CreateCodeWriter()` in the output callback.
When emitter methods need both logging/context services and writing, either pass the context and
output-scoped writer separately, or compose them into a short-lived output wrapper created inside
the callback. Such a wrapper must never be returned from an incremental provider:
```csharp
public sealed class GenerationOutputContext : ISourceGenLogger
where TContext : GenerationContext
{
public GenerationOutputContext(TContext generation)
{
Generation = generation;
Writer = generation.CreateCodeWriter();
}
public TContext Generation { get; }
public CodeWriter Writer { get; }
public void Log(
SourceGenLogLevel level,
int indentation,
string message,
params object[] args) =>
Generation.Log(level, indentation, message, args);
}
```
The wrapper reduces emitter parameter noise without extending the writer's lifetime into Roslyn's
incremental cache.
## GeneratorResult and diagnostics that don't stop generation
`IncrementalPipeline.RegisterSourceOutput` combines targets with the generation context, reports
diagnostics, and runs the generator callback only for successful results:
```csharp
var targets = IncrementalPipeline.ForAttributeWithMetadataName(
context,
AttributeType,
static (ctx, ct) =>
{
var symbol = ctx.TargetSymbol;
return symbol is null
? GeneratorResult.Empty
: GeneratorResult.Create(symbol.Name);
}
);
var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context);
IncrementalPipeline.RegisterSourceOutput(
context,
targets,
contextProvider,
static (spc, name, generationContext) =>
{
var writer = generationContext.CreateCodeWriter();
writer.Comment($"generated {name}");
spc.AddSource($"{name}.g.cs", writer.ToString());
}
);
```
The registered callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result
carries a value and none of its carried diagnostics are blocking.
`ReportableDiagnostic.IsBlocking` is an explicit, per-diagnostic decision, independent of the
diagnostic's severity. `GeneratorResult.ShouldProcess` is `true` when the result carries a value
and none of its diagnostics are blocking, so an `Error`-severity diagnostic can still allow
generation to continue. This is useful when the generated code helps the developer fix the problem —
for example, a generator that emits an abstract base class with methods the user must override can
report an error for each missing override while still emitting the base class, so the user can see
exactly what to implement:
```csharp
static readonly DiagnosticDescriptor MissingOverride = new(
"MYGEN001",
"Missing override",
"Type '{0}' must override '{1}'",
"Usage",
DiagnosticSeverity.Error,
isEnabledByDefault: true
);
var targets = IncrementalPipeline.ForAttributeWithMetadataName(
context,
AttributeType,
static (ctx, ct) =>
{
var symbol = ctx.TargetSymbol;
var model = new BaseModel(symbol.Name);
// An error-severity diagnostic that explicitly allows generation to continue:
// IsBlocking is false, so ShouldProcess stays true and the base class is emitted.
var diagnostic = ReportableDiagnostic.Create(
MissingOverride,
isBlocking: false,
symbol,
symbol.Name,
"Execute"
);
return GeneratorResult.Create(model, diagnostic);
}
);
```
Blocking diagnostics (`isBlocking: true`) stop generation for that target while still being reported.
`GeneratorResult.HasBlockingDiagnostics` reports whether any carried diagnostic blocked processing;
`HasErrorDiagnostics` reports the severity-based view (whether any diagnostic has an `Error`
`DefaultSeverity`).
## Disabling a generator at build time
Pass the generator's compiler-visible disable property to the context provider. Its resolved value is
included in `GenerationSettings` automatically:
```xml
true
```
```csharp
var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(
context,
nameof(MyGenerator),
"1.0.0",
disablePropertyName: "MyGenerator_Disable"
);
// In the output stage:
if (generationContext.Settings.IsSourceGeneratorDisabled)
return;
```
`IsDisabledValueProvider` remains available when expensive upstream transforms must be filtered
before they are combined with the generation context.
## Scope validation
The default generation-context provider reads the
`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` MSBuild property and threads it into
`GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws
`CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. See
[Code-Writer.md](../code-writer/#construction-and-scope-validation).
## Test logging
Framework logging is disabled in ordinary compiler runs. The testing integration enables it by
registering an isolated sink and supplying a per-run session ID through analyzer config. Context
providers create the internal logger automatically; generators do not implement a logging interface
and no logging-support source is generated.
The sink registry stores callbacks only. It never buffers log entries. If logging is disabled, the
session ID is missing, or no matching sink is registered, the provider supplies no logger and log
calls are discarded without storing entries. Test sinks own any entries they choose to capture and
are removed when the test run completes.
## Tracking names and step-cache tests
The framework's pipeline helpers assign a tracking name to every stage so cache tests can assert
which stages were recomputed. See [Step-Cache-Tests.md](../step-cache-tests/) for the tracking-name
table and the golden test matrix.
---
# Analyzers
The `Purview.SourceGeneratorFramework` package includes the
`Purview.SourceGeneratorFramework.Analyzers` assembly as an analyzer asset, together with the
`Purview.SourceGeneratorFramework.CodeFixers` code fix providers. The diagnostics are enabled
automatically when you reference `Purview.SourceGeneratorFramework` from a source generator project.
## How the analyzers are shipped
The analyzer and code-fix assemblies are built as Roslyn components
(`IsRoslynComponent = true`) and packed into the `Purview.SourceGeneratorFramework` package under
`analyzers/dotnet/cs/`. Because they are not separately packable NuGet packages, they are documented
here rather than in a standalone README.
The analyzers enforce two families of rules:
- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`, covering pipeline design,
`CodeWriter` usage, Roslyn component discovery, and extension-class conventions.
- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, plus the associated
`ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`.
## Build-time validation diagnostics
These are MSBuild diagnostics rather than compiler analyzers, so they are not tracked in
`AnalyzerReleases.*.md`:
| Code | Raised by | Summary |
|------|-----------|---------|
| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or did not declare) a source-generator analyzer file. |
| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. |
| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither part of the closure nor a compiler-host assembly. |
| `PSGFR41` | `Purview.SourceGeneratorFramework` merge tool | A component's public surface exposes framework types that the merge internalizes. Raised by the compiler analyzer of the same id at design time and by the merge pass as a `message` (default), `warning` or `error`. |
| `PSGFR42` | `Purview.SourceGeneratorFramework` merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. |
Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or
`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`).
## Rule reference
| Rule | Summary |
|------|---------|
| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. |
| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. |
| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. |
| `PSGFR15` | Pipeline model collection members should use sequence equality (e.g. `EquatableArray`). |
| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload so annotations honour the target compilation. |
| `PSGFR17` | Consume `CodeWriter` scope-returning methods (`...Scope`, `IndentedScope`) with `using`. |
| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. |
| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. |
| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over constructing `*DeclarationOptions` values manually. |
| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif` conditional-compilation directives. |
| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning` directives. |
| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. |
| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. |
| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. |
| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. |
| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. |
| `PSGFR28` | `FixableDiagnosticIds` references a diagnostic ID no analyzer in the compilation produces; the fix will never be shown. |
| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode` instead. |
| `PSGFR30` | Prefer `static` lambdas in incremental pipeline methods so the compiler never allocates a closure on the per-item hot path. |
| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. |
| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. |
| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, ...); extract the information into value types. |
| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. |
| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). |
| `PSGFR36` | Extension classes must be placed in the extended type's namespace under an `Extensions` folder. |
| `PSGFR37` | One extension class per receiver type; split classes that extend multiple types. |
| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. |
| `PSGFR39` | A non-packable Roslyn component that explicitly opts out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework, otherwise the package embeds the loose framework DLL under `analyzers/`. |
| `PSGFR40` | In Roslyn components (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) instead of a `cref`: copied documentation must not depend on cref resolution. |
| `PSGFR41` | In Roslyn components whose framework implementation is merged, a public member (or generic constraint) whose signature references an SGF type: the merge internalizes every SGF type, so the signature is left referring to an internal type. Make the member or its declaring type non-public. |
## Type-library and attribute-model diagnostics
The bundled generators carry their own diagnostic families, reported by the
`TypeLibraryValidationAnalyzer` (`TLB0001`–`TLB0019`) and the attribute-data-model validation
analyzers. These are documented on their feature pages:
- [Type-Library.md](../type-library/#validation)
- [Attribute-Data-Models.md](../attribute-data-models/)
## Code fixes
Code fix providers ship in the `Purview.SourceGeneratorFramework.CodeFixers` assembly and cover the
analyzer rules above, including:
- `AddGeneratorAttributeCodeFixProvider` — adds the missing `[Generator]` attribute (`PSGFR26`).
- `AddDiagnosticAnalyzerAttributeCodeFixProvider` — adds `[DiagnosticAnalyzer]` (`PSGFR25`).
- `AddExportCodeFixProviderAttributeCodeFixProvider` — adds `[ExportCodeFixProvider]` (`PSGFR24`).
- `MakeRoslynComponentPublicCodeFixProvider` — makes the component type public (`PSGFR27`).
- `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` — removes unused fixable diagnostic IDs (`PSGFR28`).
- `PreferTargetSymbolCodeFixProvider` — switches to `TargetSymbol` (`PSGFR31`).
- `PreferStaticLambdaCodeFixProvider` — makes pipeline lambdas `static` (`PSGFR30`).
- `PreferNullableContextOverloadCodeFixProvider` — adds the generation context to `Nullable()` /
`MakeNullable()` calls, including project-wide "Fix all" support (`PSGFR16`).
- `PipelineModelReferenceEqualityCollectionCodeFixProvider` — wraps collection members for sequence
equality (`PSGFR15`).
- `PreferStructuredCodeWriterIfBlockCodeFixProvider` — rewrites raw `if`/`else if`/`else` block text
to the structured `IfBlock`/`ElseIf`/`Else` APIs (`PSGFR23`).
- `CodeWriterToStringCodeFixProvider` — replaces embedded `CodeWriter` string interpolation (`PSGFR29`).
- `PreferInlineCodeForFrameworkCrefCodeFixProvider` — rewrites SGF XML doc `cref` targets to inline code (`Type`) (`PSGFR40`).
- `MakeComponentSurfaceNonPublicCodeFixProvider` — makes the exposing member, or its declaring type,
non-public (`PSGFR41`).
- `MakeTypeLibrarySpecNonPublicCodeFixProvider` — declares a type-library spec non-public in a merged
component (`TLB0021`).
- `AttributeDataModelSymbolPropertyCodeFixProvider` — fixes attribute-data-model symbol properties.
- `ReorganizeExtensionClassCodeFixProvider` — renames (`PSGFR35`), splits multi-receiver classes
(`PSGFR37`), moves the class under `Extensions/{ReceiverNamespace}/`, and updates referencing files
(`PSGFR36`).
- `ConvertToExtensionBlockCodeFixProvider` — converts classic methods to C# 14 `extension` blocks
(`PSGFR34`).
- `AddExtensionClassMetadataCodeFixProvider` — adds `[EditorBrowsable(EditorBrowsableState.Never)]`
to extension classes (`PSGFR38`).
- Type-library fixes — `TypeLibraryMemberAccessibilityCodeFixProvider`,
`TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`,
`RenameTypeLibrarySpecCodeFixProvider`, and `TypeLibraryMemberTypeCodeFixProvider`.
See [Guide.md](../guide/#19-extension-class-conventions) for the extension-class conventions the
`PSGFR34`–`PSGFR38` rules enforce.
## Roslyn component discovery
The compiler host only loads a source generator, diagnostic analyzer, or code fix provider when three
conditions hold. Missing any one means the component is **silently ignored**:
1. **The type is public** (`PSGFR27`).
2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or
`[ExportCodeFixProvider]` (`PSGFR24`).
3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or
referenced with `OutputItemType="Analyzer"` in a project reference.
A code fix provider also only appears when the diagnostic ID in `FixableDiagnosticIds` is actually
produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix providers when
the analyzer set loads, so after adding or updating a fixer assembly you must restart Visual Studio or
reload the project for the fixes to appear.
## License
This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project.
---
# Testing
`Purview.SourceGeneratorFramework.Testing` is the framework-agnostic test runner and assertion
library for unit testing incremental C# source generators.
## Installation
```bash
dotnet add package Purview.SourceGeneratorFramework.Testing
```
## What's included
- **`SourceGeneratorTestRunner`** — compiles a snippet of C# source, runs the generator,
automatically registers an isolated framework logging sink, and returns a `DriverRunResult` with
generated syntax trees, the output compilation, and captured log entries.
- **`SourceGeneratorTestBase`** — abstract base class that accepts an `ITestOutput`
instance for framework-specific logging integration.
- **`SourceGeneratorTestOptions`** — options for configuring references, namespaces, analyzer-config
values, output kind, and whether to emit the output compilation to an assembly.
- **`DriverRunResult`** — wrapper around `GeneratorDriverRunResult` that exposes generated trees, the
output compilation, emitted assembly, and log entries.
- **`DriverRunResultExtensions`** — assertion helpers such as `AssertNoCompilationErrors`,
`AssertNoGenerationExceptions`, `AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, and
more.
- **`ITestOutput`** / **`NullTestOutput`** — abstraction for capturing generator log output during
tests.
## Usage
Reference the package from a test project and write a test using the runner directly:
```xml
```
```csharp
using Purview.SourceGeneratorFramework.Testing;
public class MyGeneratorTests
{
[Test]
public async Task GeneratesExpectedSource()
{
var source = """
[MyNamespace.MyAttribute]
public partial class MyClass { }
""";
var runner = new SourceGeneratorTestRunner();
var result = await runner.RunAsync(source);
result.AssertNoCompilationErrors();
var generated = result.AssertSingleGeneratedSource();
}
}
```
Or derive from `SourceGeneratorTestBase` and plug in your own `ITestOutput`
implementation.
## Running the generator in the test project
Sometimes the test project's own source uses types produced by the generator — for example, an
integration test may attach a generated marker attribute to a fixture class while also passing the
generator type to `SourceGeneratorTestRunner`.
Reference the generator project twice, once in each role:
```xml
```
The analyzer reference makes generated declarations available to the test project's compilation. The
normal reference makes the generator's CLR type available to the testing API. These are separate from
the in-memory compilation created by `SourceGeneratorTestRunner`; source supplied to the runner is
still compiled and generated independently.
The normal reference also exposes the generator's assembly dependencies to every target framework of
the test project. This framework is built against Roslyn 5.0, which ships `net8.0` and `net9.0`
package assets, so tests targeting .NET 8, .NET 9, and .NET 10 can all load the test runner. The
Roslyn version used to compile a generator establishes the minimum compiler-host requirement for
projects that consume it as an analyzer — Roslyn 5.0 means `.NET 10` SDK / Visual Studio 2026 or
later. Do not centrally pin `System.Collections.Immutable` to a newer runtime version merely to make
the generator load.
## Options
Configure a test run with `SourceGeneratorTestOptions`:
```csharp
var options = new SourceGeneratorTestOptions
{
IncludeDefaultNamespaces = true,
AdditionalNamespaces = ["MyNamespace"],
AdditionalAssemblyTypes = [typeof(SomeExternalType)],
EnableLogging = true,
AnalyzerConfigOptions = { ["MyGenerator_Disable"] = "true" }
};
// Emitting the output to an assembly is opt-in because it is expensive.
var result = await runner.RunAsync(source, options.Compile());
```
`Compile()` is an extension method that preserves the concrete options type. A derived options record
that wants a typed default must hide the inherited `SourceGeneratorTestOptions.Default` with a typed
static, otherwise `Default.Compile()` returns the base type:
```csharp
public record MyTestOptions : SourceGeneratorTestOptions
{
public static new MyTestOptions Default => new();
}
// Returns MyTestOptions with CompileToAssembly enabled.
var result = await runner.RunAsync(source, MyTestOptions.Default.Compile());
```
### Compiled output
Emission is fully in-memory (no files are written). On .NET 8+ the emitted assembly is loaded into a
fresh **collectible `AssemblyLoadContext`**, so the result is `IDisposable` and the assembly can be
unloaded when you are done with it — keeping repeated `CompileToAssembly` runs from accumulating
assemblies in the process-wide default context:
```csharp
using var result = await runner.RunAsync(source, options.Compile());
result.CompilationResult.Assembly; // runnable assembly (may execute generated code)
result.CompilationResult.Metadata; // metadata-only MetadataLoadContext (never executes)
result.CompilationResult.MetadataAssembly; // emitted assembly reflected within that context
```
`CompilationResult.Metadata` / `MetadataAssembly` provide a metadata-only reflection view over the
emitted assembly: inspect types, members and attributes without loading it into the runtime or
executing any code. They are created lazily on first access. Dispose the result (or its
`DriverRunResult`) to unload the collectible context and release the metadata view.
Analyzer options are preserved under their supplied keys. Keys without the Roslyn `build_property.`
prefix are additionally exposed as compiler-visible MSBuild properties, so either `MyGenerator_Disable`
or `build_property.MyGenerator_Disable` can be used in tests.
## Querying produced code with `CodeQuery`
Every result type exposes a `CodeQuery` so tests can locate syntax nodes in the produced code:
```csharp
result.Generated() // DriverRunResult: generated trees (generated-first default)
result.Output() // DriverRunResult: whole output compilation
analyzerResult.Code() // AnalyzerTestResult / CodeFixTestResult: input compilation
codeFixResult.FixedCode() // CodeFixTestResult: fixed source
fixAllResult.FixedCode() // CodeFixFixAllResult / RefactorTestResult: changed documents
```
`CodeQuery` provides a `Get`/`Has`/`TryGet` family for declarations and members, generic
`Get`/`Has`, syntax-tree lookup, and type-aware matching against `TypeReference`. Every `Get`
returns a `CodeQueryResult` — the matched node (`Node`) plus a query scoped to it (`Query`) — with
implicit conversions to both the node and the scoped query, so member queries chain without
re-passing the query:
```csharp
var query = result.Generated();
query.GetClass("ServiceCollectionExtensions").HasMethod("Add", TypeReference.Create());
query.GetClass("Service").GetProperty("Count", TypeReference.Create()); // property + type
query.GetClass("Service").GetMethod("DoWork").HasParameters(intType, nullableInt, complexType);
query.GetClass("Widget", "Example.Models"); // namespace-scoped lookup
query.HasClass(new TypeReference(new TypeIdentity("Widget", "Example.Models"))); // type-identity lookup
query.GetClass(TypeIdentity.Create()); // a TypeIdentity is implicitly castable to TypeReference
query.GetClass("ResourceDefinition", 1); // generic lookup by type-parameter count
ClassDeclarationSyntax cls = query.GetClass("Service"); // implicit conversion to the node
query.GetClass("Service").Node.Members; // or use .Node for direct syntax access
```
`Get` throws `SyntaxNotFoundException` when nothing matches; `Has` returns `bool`.
Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` —
and the `TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so
`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching
the non-generic `ResourceDefinition`.
Scoped results also expose node-inspection checks through `MemberQueryExtensions`:
`HasAccessibility` (resolves C# defaults), `HasGetterAccessibility` / `HasSetterAccessibility`,
`HasBaseType`, `HasGenericTypeParameter(s)`, `GetNestedType` / `HasNestedType`, `IsInNamespace` /
`IsInGlobalNamespace`, and `GetDeclaredNamespace` on the query itself.
### Nullable expected types in tests
Tests asserting a nullable expected type can use the test-only `query.MakeNullable(type)` extension on
a `CodeQuery` (it accepts a `TypeReference` or `TypeIdentity`). It resolves the annotation against the
query's compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not
trigger the `PSGFR16` context-overload suggestion — tests have no generation context to pass.
```csharp
var query = result.Generated();
query.GetClass("Service").HasProperty("Name", query.MakeNullable(TypeReference.Create()));
```
## Refactoring tests
`RefactoringTestRunner` runs a `CodeRefactoringProvider` against a test document:
```csharp
var runner = new RefactoringTestRunner();
var result = await runner.RunAsync(
source,
new RefactorTestOptions
{
NodeSelector = query => query.GetMethod("M"),
EquivalenceKey = MyRefactoringProvider.EquivalenceKey,
});
result.FixedCode().HasMethod("M"); // query the refactored output
```
The trigger is a `Span` or a `NodeSelector` (which runs against a `CodeQuery` of the input
compilation).
## Incremental cache testing
`SourceGeneratorTestRunner.RunIncrementalAsync` runs the generator over a sequence of source sets
using a single shared driver and captures each run's tracked incremental steps, so tests can prove
each pipeline stage caches correctly:
```csharp
var result = await runner.RunIncrementalAsync([firstSources, secondSources], options);
var reasons = result.Runs[1].Steps["ForAttribute_MyAttribute"]
.SelectMany(step => step.Outputs.Select(output => output.Reason));
```
`IncrementalCacheRunExtensions.GetStepReasons()` flattens a run's steps into an
`ImmutableDictionary>`, and the TUnit assertions
`AllStepsNew`, `AllStepsCachedOrUnchanged`, `StepIsCached`, `StepIsModified`, and `HasStepReason` make
the checks fluent:
```csharp
await Assert.That(result.Runs[0]).AllStepsNew();
await Assert.That(result.Runs[1]).StepIsModified("ForAttribute_MyAttribute");
await Assert.That(result.Runs[1]).StepIsCached("GetGenerationConfiguration");
```
`RunIncrementalAsync(sources, options, ct)` runs the same source set twice (the common "unchanged
rerun is cached" case). Per-run MSBuild-property changes use `new IncrementalRunInput(sources, [...])`.
Reference cache tests live in the `Purview.SourceGeneratorFramework` source repository —
`SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests` (framework stages),
`SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests` (the canonical golden-matrix
sample), and `.../ServiceRegistrationCacheTests` (an end-to-end generator) — and should be replicated
into your own test project rather than copied from the package. See
[Step-Cache-Tests.md](../step-cache-tests/) for the full walkthrough.
## License
This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project.
---
# Testing with TUnit
`Purview.SourceGeneratorFramework.Testing.TUnit` is the TUnit integration for testing incremental C#
source generators built with `Purview.SourceGeneratorFramework`.
## Installation
```bash
dotnet add package Purview.SourceGeneratorFramework.Testing.TUnit
```
## What's included
- **`TUnitSourceGeneratorTestBase`** — ready-made base class for TUnit tests. It wires
generator log output to `TestContext.Current.OutputWriter`.
- **Custom TUnit assertions** for inspecting `DriverRunResult` instances directly in TUnit tests.
- **MSBuild `.props`** — automatically adds `global using` directives for
`Purview.SourceGeneratorFramework.Testing.TUnit` and
`Purview.SourceGeneratorFramework.Testing.TUnit.Assertions`.
## Usage
Reference the package from a TUnit test project:
```xml
```
Derive your test class from `TUnitSourceGeneratorTestBase` and use the inherited
`GenerateAsync` method:
```csharp
using Purview.SourceGeneratorFramework.Testing.TUnit;
public class MyGeneratorTests : TUnitSourceGeneratorTestBase
{
[Test]
public async Task GeneratesExpectedSource()
{
var source = """
[MyNamespace.MyAttribute]
public partial class MyClass { }
""";
var result = await GenerateAsync(source);
result.AssertNoCompilationErrors();
var generated = result.AssertSingleGeneratedSource();
await Assert.That(generated).Contains("public static partial class MyClass");
}
}
```
The base class also provides access to the underlying `SourceGeneratorTestRunner` behavior
through `GenerateAsync`.
## Using generated types in the TUnit project
If test source files use generated attributes or other generated declarations while the tests also
derive from `TUnitSourceGeneratorTestBase`, reference the generator project both as an
analyzer and as a normal assembly:
```xml
```
For example, the analyzer reference allows a test fixture to use `[MyGeneratedAttribute]`, while the
normal reference allows the test class to derive from `TUnitSourceGeneratorTestBase`. Do
not add `OutputItemType="Analyzer"` to the normal reference.
For multi-target TUnit projects, the normal reference means the generator's Roslyn dependencies
participate in reference resolution for every target. Build the generator against the Roslyn version
that supports its API usage; this framework is built against Roslyn 5.0, which ships `net8.0` and
`net9.0` package assets, so a .NET 8–10 test matrix still loads it. Compiler hosts that consume the
generator as an analyzer must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026). Do not
force a newer `System.Collections.Immutable` version through central package management.
## Running a packaged (merged) generator in tests
A component that ships as a self-contained analyzer must **not** be added as a compile-time
``. Its assembly carries the framework implementation merged into itself, and — because
ILRepack cannot internalize a framework type that reaches the component's public API surface — some
packages expose framework types publicly. Referencing such an assembly from a test project that also
loads the real `Purview.SourceGeneratorFramework.dll` (which the Testing packages do) makes every
framework type ambiguous (`CS0433`). From framework `1.0.0-prerelease.51` the merge tool guarantees a
merged component exposes no framework types apart from its own Roslyn entry points, but a merged
component remains an analyzer artifact and should still be consumed out of band.
To register a packaged generator as an additional generator/analyzer:
1. copy the analyzer DLL from the package beside the test binaries, without referencing it:
```xml
```
2. resolve the types out of band and pass them through the options:
```csharp
var assembly = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "My.Generator.dll"));
options.AdditionalGeneratorTypes =
[.. options.AdditionalGeneratorTypes, assembly.GetType("My.Namespace.MyGenerator", throwOnError: true)!];
options.AnalyzerTypes = [assembly.GetType("My.Namespace.MyAnalyzer", throwOnError: true)!];
```
`SourceGeneratorTestRunner` instantiates the supplied types with `Activator.CreateInstance`, so this is
equivalent to `typeof(...)` without the compile-time reference. Two consequences: the loaded
generator's framework copy owns its own logging registry and CodeWriter scope validation (do not
assert on its `LogEntries`, and leave `ValidateCodeWriterScopes` off for that run), and the loaded
assembly must be built against a Roslyn version compatible with the test host.
For a component in the same repository, prefer a project reference to the component project: it
resolves the **unmerged** bin output plus the loose framework DLL, so framework types keep a single
identity and `typeof(...)`, `InternalsVisibleTo`, and every assertion API keep working.
## Which base class and method
| Roslyn type | Base class | Method |
|---|---|---|
| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` |
| Diagnostic analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` |
| Code fix (single) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` |
| Code fix (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` |
| Refactoring | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` |
For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`.
## Easy starting point: derived options
Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies, then
pass it to every test:
```csharp
public sealed record MyTestOptions : SourceGeneratorTestOptions
{
public MyTestOptions()
{
AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace");
AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(typeof(SomeDependencyType), typeof(TypeIdentity));
DisableSourceGeneratorPropertyName = "DisableMyGenerator";
}
}
public class MyGeneratorTests : TUnitSourceGeneratorTestBase { ... }
```
Use `options.Compile()` for `CompileToAssembly`, and the
`OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` hooks for per-run customisation. Code-fix/refactoring
tests select actions with `EquivalenceKey` or `CodeActionIndex` (and
`RefactorTestOptions.NodeSelector`/`Span`).
## Assertion extensions
All assertion extensions are under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions`
(globally imported). `await Assert.That(...)` is terminal and returns the value:
- `HasGeneratedMethod` / `HasGeneratedMethodReturnType` / `HasGeneratedClass` / `HasGeneratedProperty` /
`HasGeneratedField` / `HasGeneratedSyntaxTree` — return the syntax node;
`HasGeneratedMethod(name, TypeReference[])` matches parameter types. `HasGeneratedClass(name, arity)`
(or a `TypeIdentity` with arity) matches a generic type by its type-parameter count, so
`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching
the non-generic `ResourceDefinition`.
- `HasFixedMethod` — same for code-fix and refactoring results.
- `HasPropertyOfType` / `HasFieldOfType` / `HasMethodOfType` / `HasConstructorOfType` /
`HasAttributeOfType` / `HasNestedType` — chain from a scoped `CodeQueryResult` (for example the
result of `HasGeneratedClass`) and return the matched member. The node-producing assertions move the
chain onto the matched node, so you can append node-inspection assertions with `.And`:
```csharp
var method = await Assert.That(query)
.HasGeneratedClass("Service")
.And.HasNestedType("Builder")
.And.WithAccessibility(Accessibility.Private)
.And.HasMethodOfType("Build", []);
```
- `WithAccessibility` / `WithGetterAccessibility` / `WithSetterAccessibility` / `WithBaseType` /
`WithGenericTypeParameter(s)` / `IsInNamespace` / `IsInGlobalNamespace` — node-inspection assertions
that keep the matched node on the chain. Accessibility resolves C# defaults (an unmodified nested
type is `Private`, a top-level type `Internal`, interface/enum members `Public`, and an accessor with
no modifier inherits its property's accessibility).
- `HasDiagnostic` / `HasDiagnostics` / `HasNoDiagnostics` / `DoesNotHaveDiagnostic` /
`HasNoErrorDiagnostics`.
- `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`.
- `GeneratesCode(expected)` / `ContainsGeneratedCode(expected)` (whitespace-flattened).
The `CodeQuery` assertions operate on a `CodeQuery` directly, so they accept a query from any test
result — `result.Generated()` for generated code, `result.Output()` for the whole compilation, or
`result.FixedCode()` for fixed/refactored code. Convenience overloads on the test result types query
the generated (or fixed) code for you.
To assert a nullable expected type, use the test-only `query.MakeNullable(...)` extension: it resolves
the annotation against the query's compilation and, unlike `TypeReference.Nullable()` /
`TypeIdentity.MakeNullable()`, does not trigger the `PSGFR16` context-overload suggestion (tests have
no generation context to pass).
```csharp
var query = result.Generated();
MethodDeclarationSyntax method = await Assert.That(query).HasGeneratedMethod("DoWork", [intType, nullableInt]);
await Assert.That(query).HasGeneratedSyntaxTree("Service.g.cs");
await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results
// Scoped member chaining:
CodeQueryResult attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute);
await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String));
```
## Incremental cache tests
`GenerateIncrementalAsync` proves the pipeline caches stage-by-stage (first run `New`, identical rerun
`Cached`/`Unchanged`, targeted changes mark only the affected stage `Modified`). A reference
implementation (`ServiceRegistrationCacheTests`) lives in the `Purview.SourceGeneratorFramework` source
repository's example generator tests; replicate it in your own project with your own stage names. See
[Step-Cache-Tests.md](../step-cache-tests/) for the full walkthrough.
## License
This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project.
---
# Step-Cache Tests for Incremental Source Generators
Snapshot-testing generated source is not enough. A generator can produce perfectly correct code while
defeating almost all incremental caching: on every edit the driver re-runs every stage and regenerates
every output. The way to prove a generator caches correctly is to track the incremental pipeline steps and
assert which stages were recomputed (`Modified`/`New`) and which were reused (`Cached`/`Unchanged`) between
runs.
This page documents the framework's step-cache testing support:
- the `RunIncrementalAsync` runner and its tracked-step model;
- the tracking names every pipeline stage receives;
- the assertion API for stage-level reasons;
- the canonical sample to copy for your own generators.
The reference implementation is
`src/src/SourceGeneratorFramework.ExampleGenerator/`, and the canonical sample is
`src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests.cs`.
---
## How the runner works
`SourceGeneratorTestRunner.RunIncrementalAsync(inputs, options)` runs one shared
`GeneratorDriver` over a sequence of `IncrementalRunInput`s (each with its own sources and optional
analyzer-config overrides):
```csharp
var result = await new SourceGeneratorTestRunner().RunIncrementalAsync(
[
new IncrementalRunInput([firstSources]),
new IncrementalRunInput([changedSources]),
],
options,
cancellationToken
);
```
The driver is created with `GeneratorDriverOptions(IncrementalGeneratorOutputKind.None,
trackIncrementalGeneratorSteps: true)`, so each run's `TrackedSteps` are captured. Because the same driver
is reused and the same source set reuses the same compilation, the second run's step reasons tell you
exactly what the driver decided to recompute.
`GenerateIncrementalAsync` on `TUnitSourceGeneratorTestBase` wraps the same runner for test-base users.
## Step reasons
Each pipeline stage (tracked by its tracking name) has outputs, each carrying an
`IncrementalStepRunReason`:
| Reason | Meaning |
|--------------|-------------------------------------------------------------------------|
| `New` | The step ran for the first time. |
| `Modified` | The step ran and produced a different output than the previous run. |
| `Unchanged` | The step ran but produced an output equal to the previous run. |
| `Cached` | The step did not run; its previous output was reused unchanged. |
## Tracking names
The framework's pipeline helpers assign a tracking name to every stage. Cache tests reference these names:
| Helper | Tracking name |
|---------------------------------------------------|------------------------------------------|
| `IncrementalPipeline.PropertyValueProvider` | `GetMSBuildPropertyValue_{property}` |
| `IncrementalPipeline.GenerationContextValueProvider` | `GetGenerationContext_{Capabilities}` |
| `IncrementalPipeline` configuration provider | `GetGenerationConfiguration` |
| `IncrementalPipeline.ForAttributeWithMetadataName` | `ForAttribute_{AttributeName}` |
| `RegisterSourceOutput` extension | `RegisterSourceOutput_{OutputType}` |
Bundled generators add their own names, for example `GetAttributeDataTargets`,
`GetTypeLibrarySpecClassNames`, `GetTypeLibraryTargets`, and `GetFrameworkTypeLibraryTree` (the cached
framework `PurviewTypeLibrary` shape). `WithTrackingName` can rename any stage.
## Assertion API
`IncrementalCacheRunExtensions.GetStepReasons()` flattens a run's tracked steps into
`ImmutableDictionary>` keyed by tracking name.
TUnit assertion extensions on `IncrementalCacheRun` make the assertions fluent:
```csharp
await Assert.That(result.Runs[0]).AllStepsNew();
await Assert.That(result.Runs[1]).AllStepsCachedOrUnchanged();
await Assert.That(result.Runs[1]).StepIsModified("ForAttribute_GenerateServiceAttribute");
await Assert.That(result.Runs[1]).StepIsCached("GetGenerationConfiguration");
await Assert.That(result.Runs[1]).HasStepReason("ForAttribute_GenerateServiceAttribute", IncrementalStepRunReason.Unchanged);
```
- `AllStepsNew` — every tracked stage is `New` (a first run).
- `AllStepsCachedOrUnchanged` — every tracked stage was reused. Prefer asserting the generator's own
stages with `StepIsCached` when the generator emits marker attributes via
`RegisterPostInitializationOutput`: Roslyn's internal `ForAttributeWithMetadataName` steps can report
`Modified` on an identical rerun because the post-initialization source is regenerated as a new tree.
- `StepIsModified(stage)` — the stage was recomputed and produced different output.
- `StepIsCached(stage)` — every output of the stage was `Cached` or `Unchanged`.
- `HasStepReason(stage, reason)` — the stage contains at least one output with the given reason.
## Golden test matrix
At minimum test:
1. **First run is all `New`** — proves every stage runs once.
2. **Identical rerun is cached** — proves value-equatable models short-circuit the pipeline.
3. **Unrelated source edit recomputes but stays unchanged** — proves model equality prevents regeneration.
4. **Editing one target invalidates only that target** — proves per-target incrementality
(`ForAttributeWithMetadataName`).
5. **Changing one MSBuild/analyzer-config property invalidates only dependent stages** — proves
configuration stages are independent of target stages.
6. **Deleting/renaming a target changes its output** — proves outputs are removed and hint names follow
the model.
## Canonical sample
`src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests.cs` exercises the full matrix
against the reference generator:
```csharp
public class StepCacheTests : TUnitSourceGeneratorTestBase
{
[Test]
public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken)
{
var result = await GenerateIncrementalAsync(
[new IncrementalRunInput([Source])],
cancellationToken: cancellationToken
);
await Assert.That(result.Runs[0]).AllStepsNew();
}
[Test]
public async Task SingleTargetEdit_OnlyInvalidatesThatTarget(CancellationToken cancellationToken)
{
var result = await GenerateIncrementalAsync(
[new IncrementalRunInput([Source]), new IncrementalRunInput([SingleTargetEditSource])],
cancellationToken: cancellationToken
);
await Assert.That(result.Runs[1])
.HasStepReason("ForAttribute_GenerateServiceAttribute", IncrementalStepRunReason.Modified);
await Assert.That(result.Runs[1])
.HasStepReason("ForAttribute_GenerateServiceAttribute", IncrementalStepRunReason.Unchanged);
}
}
```
Mirror this pattern in every generator project; see the `*CacheTests` classes in
`SourceGeneratorShared.UnitTests`, `SourceGeneratorFramework.Generators.UnitTests`, and
`SourceGeneratorFramework.ExampleGenerator.UnitTests` for per-generator variants.
---
# Packaging
This page covers how to package a source generator that references
`Purview.SourceGeneratorFramework`, and how the framework package itself is assembled and validated.
## How the framework packages are assembled
`Purview.SourceGeneratorFramework` is dual-role: the built framework assembly ships in `lib/` so
generator projects can compile against it, and the `analyzers/` folder carries self-contained
generator, analyzer, and code-fixer assemblies. The bundled projects are:
- `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`;
- `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers;
- `SourceGeneratorFramework.CodeFixers` — the code fix providers.
Each Roslyn component has the framework implementation merged and internalized into its own assembly.
The package deliberately does **not** put `Purview.SourceGeneratorFramework.dll` under
`analyzers/dotnet/cs`. Consequently, generators built against different framework versions do not
ask Roslyn to load competing versions of a same-named runtime dependency.
The shared models and helpers that used to ship as a separate
`Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly
(`SourceGeneratorFramework` links the `SourceGeneratorShared` sources via
`SourceGeneratorShared.Link.targets`). Consumers therefore receive a single
`Purview.SourceGeneratorFramework.dll`, which removes the version-skew hazard that a separately
bundled Shared assembly caused: generator packages that loaded a different Shared version in-process
failed with binary-incompatibility errors (e.g. removed `PurviewTypeLibrary` fields).
These projects are `IsRoslynComponent = true` and are **not** packable on their own; they are packed
into the main package by the `SourceGeneratorFramework` project. They were previously consumed as
analyzer project references, but since they now reference the framework assembly for the shared types
(which would form a project-reference cycle), the `SourceGeneratorFramework` project builds them via
`GetPurviewMergedAnalyzerFile` and packs them under `analyzers/dotnet/cs/` in
`BuildAndPackBundledAnalyzerAssemblies`.
The repo's pack validation (`purview-build.json`) requires the `purview.sourcegeneratorframework`
package to contain, at minimum:
- `lib/netstandard2.0/Purview.SourceGeneratorFramework.dll`;
- `analyzers/dotnet/cs/` versions of the self-contained generators, analyzers, and code fixers;
- `build/Purview.SourceGeneratorFramework.props` and `build/Purview.SourceGeneratorFramework.targets`;
- `tools/net10.0/` versions of the framework-owned merge tool and its runtime files;
- `README.md`, `LICENSE.md`, and `purview-logo-light.png`.
PDBs are delivered only through the `.snupkg`; `*.pdb` files are forbidden inside the `.nupkg`.
## Referencing a generator from a consuming project
Reference the framework privately from a Roslyn component. When the generator project itself is
packed, the framework's build target replaces its output with a self-contained assembly at pack time:
```xml
true
```
Use an analyzer project reference from a consuming project so Roslyn receives the generator
assembly:
```xml
```
The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns the generator
assembly without adding it to the consuming application's runtime references. By default
(`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=true`) the framework returns the **merged,
self-contained** generator from the intermediate `purview-merged/` directory, so consuming projects
and any GASF-based package compile against a generator that carries its own framework implementation
and never needs the loose `Purview.SourceGeneratorFramework.dll`. The generator's bin output is left
unmerged, so a project that references the generator assembly directly (an in-process test harness)
keeps its shared framework type identity, `InternalsVisibleTo` access, and avoids `CS0433`
collisions with the framework library. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"`
explicitly remains supported but is not required.
Set `PurviewEmbedSourceGeneratorFramework` to `false` only for a project that produces the framework
compile-time library itself. Published generator packages must not disable embedding.
Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the generator's
analyzer-files consumers must keep the unmerged assembly + loose framework DLL shape (for example a
generator shipped into a single compiler process alongside an incompatible framework version).
### Analyzer consumption contract
A component that references the framework is consumed in three distinct ways, each producing a
different shape:
| Path | Trigger | Output |
| --- | --- | --- |
| `GetSourceGeneratorAnalyzerFiles` (default) | A consuming project references the component as an analyzer | The **merged**, self-contained component, returned from the content-addressed intermediate `purview-merged//` directory. The component's bin output stays unmerged, so its in-process test harness retains shared framework type identity and `InternalsVisibleTo` access without `CS0433` collisions. |
| `GetSourceGeneratorAnalyzerFiles` (opt-out) | Same, with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | Unmerged component + the loose `Purview.SourceGeneratorFramework.dll` copied from the framework package `lib/`. |
| `GetPurviewMergedAnalyzerFile` | The framework package's own bundled-component pack, or a third-party package embedding the generator | The **merged** component from the intermediate output; the component's bin is never overwritten. |
| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | Packing a standalone, packable generator project | The **merged**, self-contained DLL (and its PDB) is contributed directly to `analyzers/dotnet/cs`. The component's bin output is never mutated, so its on-disk shape does not depend on whether `build` or `pack` ran last. |
### A component that references another component
A code-fix (or analyzer) component can reference the generator component normally
(`ProjectReference`, `ReferenceOutputAssembly` not `false`) when it needs the generator's internal
diagnostic identity. Both components are `IsRoslynComponent`; only the generator references the
framework.
- The generator component merges at build time. Its **analyzer artifact** is the merged,
self-contained assembly from `obj/.../purview-merged//`; its **bin output** stays unmerged and
references `Purview.SourceGeneratorFramework`.
- Because a package consumer's framework `lib/` asset is not copied to output, the framework
assembly is declared as copy-to-output content beside the generator's bin output. Content with
`CopyToOutputDirectory` flows transitively through `ProjectReference`, so the dependent code-fix
component's bin folder is self-sufficient too — that is what lets Visual Studio load the code-fix
provider from its own bin without a `FileNotFoundException` for the framework assembly.
- The dependent component's **analyzer closure** (what `GetSourceGeneratorAnalyzerFiles` returns)
includes the referenced component's analyzer artifact. The referenced component is returned as its
own merged assembly and is **never** IL-merged into the dependent component, which would duplicate
its types (including `InternalsVisibleTo`-visible internals) inside a second analyzer in the same
host.
- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the
referenced project's default target path — the component's **unmerged** bin assembly — and adds it
to the compiler's analyzers, which the command-line build does not. `Purview.BuildSdk` removes that
item before adding the resolved closure, so Roslyn only ever receives the merged artifact.
Otherwise the unmerged copy drags `Purview.SourceGeneratorFramework` into the compiler host
(`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`)
and the generators are registered twice.
### Build-time analyzer-closure validation
`Purview.BuildSdk` validates the whole closure returned by `GetSourceGeneratorAnalyzerFiles`: every
file's PE `AssemblyRef` must resolve to another file in the returned set or to a compiler-host
assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing
component artifact fails the build with `PRSGD0005`. Extend the permitted set with
`` (or the
`PurviewAnalyzerClosurePermittedReferences` property). Opt out with
`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`.
### Generator-read MSBuild properties
A generator that reads `build_property.` is only correct if `` is a
`CompilerVisibleProperty` wherever the generator runs. Declare the properties a component reads:
```xml
```
`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the
project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets (which is what
consumers receive). Opt out with
`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`.
NuGet imports `buildTransitive` assets for **PackageReference** consumers only. While developing the
package repository itself every project uses `ProjectReference`, so
`Purview.BuildSdk`'s `ImportProjectReferencedBuildTransitiveAssets` target registers the referenced
project's `Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo
consumers. Opt out with `PurviewImportProjectReferenceBuildTransitive=false`.
In the opt-out path the loose framework DLL is declared as a `SourceGeneratorRuntimeDependency`
(statically from the framework package `lib/` for package consumers, with a target-time fallback for
in-repo `ProjectReference` components) so the SDK copies it beside the generator before Roslyn loads
it. The merged paths never declare it.
The merge itself (`_PurviewMergeSourceGeneratorFramework`) only writes to the component's
intermediate output. `GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged
path into `TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving
`GetTargetPath` — which resolves assembly references — pointing at the unmerged bin. This is what
keeps the in-repo test harness working while shipped assemblies stay self-contained.
The merged artifact is **content-addressed**: it lives in
`$(IntermediateOutputPath)purview-merged//$(TargetFileName)`, where `` is a
SHA-256 of the component, the framework assembly and the merge tool (the merge tool computes it via
its `--tag` mode). The tag is in the *directory*, never the file name, because ILRepack derives the
merged assembly's simple name from the output file name and an analyzer must keep
`.dll`.
That gives two properties the earlier fixed-name output could not:
- a rebuild with identical inputs reuses the existing file and **skips the merge**, keeping
`_PurviewMergeSourceGeneratorFramework` idempotent across the per-consumer (and potentially
parallel) invocations of `GetSourceGeneratorAnalyzerFiles`; and
- changed inputs select a **new directory**, so the merge never overwrites a merged assembly that a
compiler host (csc/`VBCSCompiler`/Visual Studio) already has loaded. Overwriting such a file fails
on Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code
`-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of
`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`.
The merge tool merges into a per-process `.staging-` directory and publishes it with a
non-overwriting directory rename; a concurrent invocation that loses the race simply observes the
published artifact instead of failing.
Packaging needs the stable `.dll` name, so the pack paths
(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed
artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy.
### Framework type internalization in merged components
ILRepack's `Internalize` is best-effort: a framework type that reaches the merged component's public
API surface stays public, and the types the framework's own generators emit into the component (the
`Purview.SourceGeneratorFramework.Generators` attribute set, generated type libraries, marker
attributes) are not part of the merged framework assembly at all, so ILRepack never sees them. Either
gap leaks framework types out of what must be a self-contained analyzer, and any project that loads
that analyzer alongside the real framework assembly fails with `CS0433` ambiguity for every leaked
type.
The merge tool therefore runs a deterministic internalization pass over the merged output
(`FrameworkTypeInternalizer`) after `ILRepack` finishes:
- every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children,
including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated
through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type:
the framework's nested containers (the generated type-library namespace classes, the nested
operator/enum groups, the `CodeWriter` scopes) are only reachable through their declaring type;
- the types the framework's own generators emit into the component — recognized by the tool name on
their `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`,
`AttributeDataModelGenerator`) — become non-public as well, so a generated type library can never
leak a `TypeIdentity`/`TypeReference` member as public API in the shipped analyzer;
- `Microsoft.CodeAnalysis.EmbeddedAttribute` (the framework-emitted marker) becomes non-public;
- **Roslyn component entry points are never internalized** — a generator, analyzer, code fix
provider, or refactoring provider that is reachable from the framework namespace stays public,
because Roslyn only instantiates public components (PSGFR27). This is what keeps the framework's
own bundled analyzers working after the pass;
- the pass reports the merge result: leftover public framework types fail the merge (exit code `5`),
and public component members whose signature exposes a framework type are logged so the component
author can make them (or their declaring type) non-public. The report follows visibility through the
declaring chain too: a public nested type inside an internal type — for example the
compiler-synthesised `$`/`$` extension containers emitted for an extension class — is not
reachable from outside the component, so it is not part of the public surface and is not reported.
Ownership is seeded with the framework assembly's own type identities, so a framework type that lives
outside the framework namespace (the `Microsoft.CodeAnalysis.*Extensions` and `System.StringExtensions`
extension classes, for example) is internalized as well, and the artifact's assembly-level
`InternalsVisibleTo` / `IgnoresAccessChecksTo` grants are removed because a shipped analyzer is not the
component's own assembly.
The pass is configurable from the component's project:
| Property | Default | Effect |
|----------|---------|--------|
| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. |
| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. |
| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none` (dropped). |
| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. |
| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; set it to a source or project path for IDE navigation. |
All five participate in the merge's content tag, so changing one re-runs the merge (and re-emits its
findings) instead of reusing a cached artifact. The compiler analyzer of the same id (`PSGFR41`) reports
the same condition while you edit, so the surface is fixed before the merge runs.
The component's own generated types (the type library, attribute data models) keep their generated
accessibility in the component's **bin output**: TLB0015 requires a hand-written partial to be declared
`public static partial` so it can merge with the generated library, and in-repo consumers such as code
fixers and sibling assemblies compile against it. Only the merged analyzer artifact internalizes them
(see above), so self-containment is enforced at the merge boundary without rewriting the generated
accessibility an in-repo consumer compiles against. A merged artifact is never referenced as a
compile-time dependency, which is what makes that split safe. See [Type-Library.md](../type-library/).
> A merged component is an analyzer artifact and must never be referenced as a compile-time
> dependency. Tests that need to run a *packaged* generator load it out of band — see
> [Testing-TUnit.md](../testing-tunit/).
The bundled `SelfContainedGeneratorAnalyzer` (PSGFR39) runs on every project that references the
framework and errors when a **non-packable** Roslyn component explicitly opts out of the default
self-contained analyzer output. Such a component, if embedded into a package through the GASF-based
pack, forces the loose `Purview.SourceGeneratorFramework.dll` under `analyzers/`, reintroducing the
shared-version hazard.
The analyzer reads the following compiler-visible properties:
`IsRoslynComponent`, `IsPackable`, `PurviewEmbedSourceGeneratorFramework`,
`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles`, and
`PurviewSourceGeneratorFrameworkAnalyzerValidation`. It does not report when the component:
- is not a Roslyn component, or does not reference the framework;
- disables embedding (`PurviewEmbedSourceGeneratorFramework=false`);
- is packable (`IsPackable=true`) — its own `GenerateNuspec` merge makes the package self-contained;
- keeps the default merged GASF output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=true`);
- explicitly opts out (`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`).
Set `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` on a component that is shipped
self-contained via `GetPurviewMergedAnalyzerFile`, or that is only consumed in-repo and never packed.
Packaging an embedded generator through the raw GASF path without one of the self-contained
arrangements is an error.
### `IsExternalInit` contract
The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly** so the
framework's own bundled generators can emit `init`-based attribute types into any consumer
compilation, and so the merge step has a single marker definition to internalize. Consumers
(generator projects) should not declare their own `IsExternalInit`.
A generator-local marker gives calls to the framework's `init` setters a different required custom
modifier identity from the setter definitions. Older merge-tool versions passed both identities to
ILRepack, which could emit `Method reference is used with definition return type / parameter`
warnings while rewriting the component.
For compatibility with generators that still receive a local marker from legacy source or build
tooling, the merge tool normalizes those required modifiers to the framework marker in a temporary
copy before merging. The generator's bin output is not changed, and the shipped self-contained
analyzer contains one internalized `IsExternalInit` definition. Removing the redundant marker from
the generator project remains the preferred configuration.
### Generators embedded in another package
If the generator assembly is embedded in a different NuGet package, pack the generator's **merged,
self-contained** assembly so the outer package does not ship a loose `Purview.SourceGeneratorFramework.dll`.
Call the framework's `GetPurviewMergedAnalyzerFile` target on the generator project (which merges the
framework implementation into the generator's intermediate output without touching its bin) and add
the returned file under `analyzers/dotnet/cs`, disabling the SDK's default GASF-based analyzer packing:
```xml
false
$(TargetsForTfmSpecificContentInPackage);PackMyGenerator
analyzers/dotnet/cs/
```
If the generator assembly is also embedded at compile time for the outer package's consumers, the
outer package must make the framework's compiler-visible properties visible to those consumers. Build
assets from `Purview.SourceGeneratorFramework` are not automatically copied into the outer package.
Include a `.props` file imported by the outer package that declares the property and its
`CompilerVisibleProperty` entry (see
[Code-Writer.md](../code-writer/#generators-embedded-in-another-package)), and pack it using the outer
package's ID so NuGet imports it automatically:
```xml
```
## Roslyn version compatibility
The most important packaging rule is:
> **The version of `Microsoft.CodeAnalysis.*` used to compile your analyzer/generator establishes a
> minimum compiler-host API requirement.**
The consumer's `` does not determine analyzer compatibility. Analyzer/generator code
executes inside a compiler/IDE host. Microsoft's published baseline for the framework's Roslyn
generation is:
| Roslyn package | Minimum Visual Studio | Language / .NET generation |
| ---: | --- | --- |
| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 |
| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 |
| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 |
> **This framework is built against Roslyn 5.0.** The generator, analyzer, and testing assemblies in
> `Purview.SourceGeneratorFramework*` are compiled against `Microsoft.CodeAnalysis` 5.x, so compiler
> hosts that load them must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026 18.0). The
> testing packages multi-target `net8.0`–`net10.0`; Roslyn 5.x ships `net8.0`/`net9.0` package assets,
> so those test targets still load the test runner.
See [Guide.md](../guide/) sections 14–18 for the full discussion of Roslyn versioning, multi-version
packaging strategies, and the recommended generator project configuration.
## Recommended generator project configuration
A broadly-compatible generator project might start with:
```xml
netstandard2.0
latest
enable
true
false
true
true
```
The resulting generator package contains the generator DLL under `analyzers/dotnet/cs`; it does not
contain a loose `Purview.SourceGeneratorFramework.dll`. Package validation should inspect both the
ZIP entries and the generator's assembly references to enforce that invariant.
Then centrally define:
```xml
4.8.0
5.9.0
```
The exact Roslyn baseline is a product-support decision.
## Release gates
The following checks are the acceptance criteria for the self-contained packaging:
1. **No assembly reference and no public framework types** — every shipped Roslyn component DLL
(`analyzers/dotnet/cs/*.dll`) has no assembly reference to `Purview.SourceGeneratorFramework` and
exposes **no public type** in a framework-owned namespace (`Purview.SourceGeneratorFramework` and
its children, including the generated `Generators` attribute set, plus the framework-emitted
`Microsoft.CodeAnalysis.EmbeddedAttribute`). The only exception is the component's own Roslyn
component entry points, which must stay public because Roslyn only instantiates public components.
Inspect the metadata directly; do not rely on "the sample compiled". The merge tool fails with exit
code `5` when a merge leaves public framework types behind, and logs a warning naming any public
member that still exposes a framework type.
2. **No loose framework DLL in packages** — no `.nupkg` contains
`Purview.SourceGeneratorFramework.dll` under `analyzers/`, and `*.pdb` files are forbidden in the
`.nupkg` (symbols ship only through the `.snupkg`).
3. **Merged entry points survive** — each merged DLL still exposes its `IIncrementalGenerator`,
`DiagnosticAnalyzer`, or `CodeFixProvider` implementations.
4. **Installed-package consumer test** — a generator project that references the framework package
by `PackageReference` (with the framework's `build/` targets auto-imported) packs a single
self-contained DLL under `analyzers/dotnet/cs`, and a consumer that installs that package builds
with the generator producing output.
5. **Two-version coexistence test** — build Generator A against the current framework and
Generator B against an intentionally binary-incompatible framework version (for example a v2
that adds a `CodeWriter` member Generator B calls). Install both packages into one consumer and
build with both package-reference orders. Both generators must run, each against its own embedded
framework copy. Under the old shared-DLL model one generator fails with `MissingMethodException`
(load-order dependent); with self-contained packaging both succeed.
## License
This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project.
---
# Performance
Benchmark results are produced by the benchmarks project
([`SourceGeneratorFramework.Benchmarks`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.Benchmarks)) using
[BenchmarkDotNet](https://benchmarkdotnet.org) and folded here for reference.
## What is measured
All benchmarks measure the **production** code path: generator runs configure
`ValidateCodeWriterScopes = false` and the writer is constructed with `throwOnUnclosedScopes: false`.
Scope tracking is a testing/debug feature and is excluded here because capturing an opening
`StackTrace` per scope dominates both time and allocation (for 1000 small classes it inflates the
writer benchmark from ~1.6 ms/2.3 MB to ~19 ms/22 MB). Tests opt into it so an unclosed `using` or
block fails fast; see [Code-Writer.md](../code-writer/#construction-and-scope-validation).
## Environment
- BenchmarkDotNet v0.15.8
- Windows 11 (10.0.28020.2991)
- 13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores
- .NET SDK 10.0.401
- .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
- Toolchain: InProcessEmitToolchain
## CodeWriter
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
| ---------- |---------:|---------:|---------:|----------:|---------:|--------:|----------:|
| ManyClasses | 1.612 ms | 0.0139 ms | 0.0130 ms | 142.5781 | 142.5781 | 142.5781 | 2.34 MB |
## AttributeDataModelGenerator
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
| -------- |---------:|----------:|----------:|--------:|--------:|----------:|
| RunAsync | 1.133 ms | 0.0182 ms | 0.0171 ms | 54.6875 | 11.7188 | 1.03 MB |
## EquatableArray
| Method | Count | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio |
| ------------------------------ |------ |------------:|----------:|----------:|------------:|------:|--------:|----------:|------------:|
| **EquatableArrayEquals** | **10** | **4.1603 ns** | **0.1068 ns** | **0.1271 ns** | **4.1864 ns** | **1.001** | **0.04** | **-** | **NA** |
| EquatableArrayGetHashCode | 10 | 0.1677 ns | 0.0091 ns | 0.0085 ns | 0.1688 ns | 0.040 | 0.00 | - | NA |
| ImmutableArrayReferenceEquals | 10 | 0.0032 ns | 0.0069 ns | 0.0061 ns | 0.0000 ns | 0.001 | 0.00 | - | NA |
| ImmutableArraySequenceEqual | 10 | 3.7109 ns | 0.0374 ns | 0.0350 ns | 3.7080 ns | 0.893 | 0.03 | - | NA |
| | | | | | | | | | |
| **EquatableArrayEquals** | **100** | **29.9631 ns** | **0.2494 ns** | **0.2333 ns** | **29.9944 ns** | **1.000** | **0.01** | **-** | **NA** |
| EquatableArrayGetHashCode | 100 | 0.1705 ns | 0.0086 ns | 0.0081 ns | 0.1685 ns | 0.006 | 0.00 | - | NA |
| ImmutableArrayReferenceEquals | 100 | 0.0029 ns | 0.0041 ns | 0.0038 ns | 0.0004 ns | 0.000 | 0.00 | - | NA |
| ImmutableArraySequenceEqual | 100 | 29.3123 ns | 0.2250 ns | 0.2105 ns | 29.3094 ns | 0.978 | 0.01 | - | NA |
| | | | | | | | | | |
| **EquatableArrayEquals** | **1000** | **189.2828 ns** | **1.3004 ns** | **1.2164 ns** | **189.2343 ns** | **1.000** | **0.01** | **-** | **NA** |
| EquatableArrayGetHashCode | 1000 | 0.1855 ns | 0.0109 ns | 0.0102 ns | 0.1817 ns | 0.001 | 0.00 | - | NA |
| ImmutableArrayReferenceEquals | 1000 | 0.0275 ns | 0.0178 ns | 0.0167 ns | 0.0251 ns | 0.000 | 0.00 | - | NA |
| ImmutableArraySequenceEqual | 1000 | 191.8212 ns | 2.4799 ns | 2.3197 ns | 191.5713 ns | 1.013 | 0.01 | - | NA |
## ForAttributeTransform
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
| -------------------------- |------------:|----------:|----------:|------:|-------:|----------:|------------:|
| GetDeclaredSymbolFromNode | 115.5924 ns | 0.8672 ns | 0.7688 ns | 1.000 | 0.0017 | 32 B | 1.00 |
| PreResolvedTargetSymbol | 0.5658 ns | 0.0182 ns | 0.0170 ns | 0.005 | - | - | 0.00 |
## SourceGeneratorTestRunner
| Method | ClassCount | CompileToAssembly | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
| -------- |----------- |------------------ |--------------:|------------:|------------:|--------:|--------:|-----------:|
| **RunAsync** | **1** | **False** | **5.725 μs** | **0.0552 μs** | **0.0489 μs** | **0.8545** | **0.2136** | **15.79 KB** |
| **RunAsync** | **1** | **True** | **7,078.346 μs** | **133.0041 μs** | **124.4121 μs** | **23.4375** | **7.8125** | **458.51 KB** |
| **RunAsync** | **10** | **False** | **7.579 μs** | **0.0873 μs** | **0.0729 μs** | **1.0147** | **0.2518** | **18.73 KB** |
| **RunAsync** | **10** | **True** | **7,746.954 μs** | **150.9553 μs** | **239.4311 μs** | **23.4375** | **7.8125** | **555.61 KB** |
| **RunAsync** | **100** | **False** | **25.144 μs** | **0.5020 μs** | **0.5580 μs** | **2.7161** | **0.4272** | **50.19 KB** |
| **RunAsync** | **100** | **True** | **10,294.152 μs** | **203.6992 μs** | **382.5964 μs** | **78.1250** | **15.6250** | **1552.67 KB** |
## TypeIdentity
| Method | Mean | Error | StdDev | Allocated |
| ---------------------- |---------:|---------:|---------:|----------:|
| Int32Identity | 15.19 ns | 0.207 ns | 0.193 ns | - |
| StringIdentity | 14.85 ns | 0.266 ns | 0.236 ns | - |
| ListOfStringIdentity | 16.72 ns | 0.348 ns | 0.386 ns | - |
| DictionaryIdentity | 16.61 ns | 0.289 ns | 0.271 ns | - |
| NestedGenericIdentity | 16.43 ns | 0.323 ns | 0.303 ns | - |
## TypeLibraryGenerator
| Method | SpecCount | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
| -------- |---------- |---------:|----------:|----------:|---------:|--------:|----------:|
| **RunAsync** | **1** | **1.958 ms** | **0.0297 ms** | **0.0278 ms** | **62.5000** | **11.7188** | **1.16 MB** |
| **RunAsync** | **5** | **2.555 ms** | **0.0368 ms** | **0.0344 ms** | **97.6563** | **23.4375** | **1.81 MB** |
| **RunAsync** | **20** | **4.913 ms** | **0.0965 ms** | **0.1414 ms** | **234.3750** | **39.0625** | **4.27 MB** |
## Regenerating the results
Run the benchmarks project and copy the generated reports into the tables above:
```bash
dotnet run -c Release --project src/src/SourceGeneratorFramework.Benchmarks --framework net10.0
```
Filter to a single benchmark with `--filter "*Name*"`. The Markdown reports are written to
`BenchmarkDotNet.Artifacts/results/`.
---
# Release Flow
This page documents how the repository builds, tests, packs, and releases
`Purview.SourceGeneratorFramework`.
## Versioning
The current version lives in the repository-root `package.json`:
```json
{
"name": "purview-sourcegenerator-framework",
"version": "1.0.0-prerelease.42"
}
```
The version is read by the build tooling (for example `just version` runs
`bun -p "require('./package.json').version"`), and GitHub releases are tagged `v`, e.g.
`v1.0.0-prerelease.42`.
## Workflows
### Pull requests
`.github/workflows/pr.yml` runs on `pull_request` to `main`. It calls the shared
`purview-dev/build` workflow (`purview-build.yml`) with `run-pack: true` and `validate-pack: true`, so
every PR restores, builds, lints, runs tests, packs, and validates the packages.
### Releases
`.github/workflows/release.yml` runs on `push` to `main`. It calls the shared
`purview-dev/build` workflow (`purview-release.yml`) with `release-mode: NuGet`, which builds, tests,
packs, validates, publishes to NuGet, and creates the GitHub release.
## Local pipelines
The `Justfile` wraps the shared `Purview.Build` pipeline (installed as a pinned dotnet tool to
`.tools/purview-build/purview-build`):
| Recipe | Pipeline mode | Purpose |
| --- | --- | --- |
| `just pipeline-pr` | default | Restore, build, lint, tests. |
| `just pipeline-build` | `--Build:RunTests=false --Release:Mode=None` | Build-only pipeline. |
| `just pipeline-tests` | `--Build:RunTests=true --Release:Mode=None` | Build with tests. |
| `just pipeline-release` | `--Release:Mode=NuGet` | Full release: build, test, pack, publish. |
| `just pipeline-local-release` | `--Release:Mode=LocalNuGet` | Build, test, pack, and publish to a local NuGet feed. |
Convenience recipes also exist for building (`just build`), testing (`just test`, `just test-unit`),
packing (`just pack`), benchmarking (`just benchmark`), linting (`just lint-check`/`just lint-fix`),
and cleaning (`just clean`, `just scrub`).
## Pack validation
`purview-build.json` configures pack validation:
- `PackValidation.RequireSymbolPackage` and `RequireSymbolFiles` — every packable package must ship a
`.snupkg` with symbol files.
- `PackValidation.RequiredContent` — each package must contain its declared assets. For example,
`purview.sourcegeneratorframework` must contain the `lib/netstandard2.0/` framework assembly and
shared assembly, the `analyzers/dotnet/cs/` generator/analyzer/code-fixer/shared assemblies, the
`build/Purview.SourceGeneratorFramework.props` and `.targets` files, `README.md`, `LICENSE.md`, and
`purview-logo-light.png`. See [Packaging.md](../packaging/) for details.
- `PackValidation.ForbiddenContent` — `*.pdb` files are forbidden inside the `.nupkg` (PDBs are
delivered only through the `.snupkg`).
## Dependency management
`Directory.Packages.props` centralises package versions:
- `Microsoft.CodeAnalysis.CSharp` / `Microsoft.CodeAnalysis.CSharp.Workspaces` — Roslyn 5.x
(`RoslynCompilerVersion`, currently `[5.9.0,)`).
- `Microsoft.CodeAnalysis.Analyzers` — `RoslynAnalyzersVersion` `[5.9.0,)`.
- `TUnit` / `TUnit.Core` / `TUnit.Assertions` / `TUnit.Mocks` — `[1.67.0,)`.
- `System.Reflection.MetadataLoadContext` — used by the testing package for the metadata-only
`CompilationResult` view.
Central Package Management is enabled with `CentralPackageTransitivePinningEnabled`.
## License
This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project.