.agents/skills/dotnet-csharp-source-generators/SKILL.md
Creates Roslyn source generators. IIncrementalGenerator, GeneratedRegex, LoggerMessage, STJ.
npx skillsauth add dodyg/blue-nile-pds dotnet-csharp-source-generatorsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Guidance for both creating and consuming Roslyn source generators in .NET. Creating: IIncrementalGenerator, syntax providers, semantic analysis, emit patterns, diagnostic reporting, testing with CSharpGeneratorDriver. Consuming: [GeneratedRegex], [LoggerMessage], System.Text.Json source generation, [JsonSerializable].
Cross-references: [skill:dotnet-csharp-modern-patterns] for partial properties and related C# features, [skill:dotnet-csharp-coding-standards] for naming conventions.
Source generators are shipped as analyzers targeting netstandard2.0.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
</ItemGroup>
</Project>
Always target
netstandard2.0. Generators load into the compiler process, which requires this TFM for compatibility. UseLangVersion>latestto write modern C# in the generator itself.
IIncrementalGenerator (Preferred)Always use IIncrementalGenerator over the legacy ISourceGenerator. Incremental generators are cache-aware and only re-run when inputs change, making them significantly faster in IDE scenarios.
[Generator]
public sealed class AutoNotifyGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Step 1: Filter syntax nodes to candidate fields
var fieldDeclarations = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MyLib.AutoNotifyAttribute",
predicate: static (node, _) => node is FieldDeclarationSyntax,
transform: static (ctx, _) => GetFieldInfo(ctx))
.Where(static info => info is not null)
.Select(static (info, _) => info!.Value);
// Step 2: Group fields by containing type, then emit one file per type
context.RegisterSourceOutput(fieldDeclarations.Collect(),
static (spc, fields) => Execute(fields, spc));
}
private static FieldInfo? GetFieldInfo(
GeneratorAttributeSyntaxContext context)
{
var fieldSymbol = context.TargetSymbol as IFieldSymbol;
if (fieldSymbol is null)
return null;
var containingType = fieldSymbol.ContainingType;
// Use fully qualified type name to handle generic and nested types
var fullTypeName = containingType.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat
.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted));
return new FieldInfo(
fieldSymbol.ContainingNamespace.IsGlobalNamespace
? ""
: fieldSymbol.ContainingNamespace.ToDisplayString(),
containingType.Name,
fullTypeName,
fieldSymbol.Name,
fieldSymbol.Type.ToDisplayString());
}
private static void Execute(
ImmutableArray<FieldInfo> fields,
SourceProductionContext context)
{
// Group by fully qualified type name to emit one file per class
foreach (var group in fields.GroupBy(f => f.FullTypeName))
{
var first = group.First();
var ns = first.Namespace;
var className = first.ClassName;
var properties = new StringBuilder();
foreach (var field in group)
{
var propertyName = GetPropertyName(field.FieldName);
properties.AppendLine($$"""
public {{field.FieldType}} {{propertyName}}
{
get => {{field.FieldName}};
set
{
if (!global::System.Collections.Generic.EqualityComparer<{{field.FieldType}}>.Default.Equals({{field.FieldName}}, value))
{
{{field.FieldName}} = value;
PropertyChanged?.Invoke(this,
new global::System.ComponentModel.PropertyChangedEventArgs(nameof({{propertyName}})));
}
}
}
""");
}
// Handle global namespace (no namespace declaration)
var nsBlock = string.IsNullOrEmpty(ns) ? "" : $"namespace {ns};\n\n";
var source = $$"""
// <auto-generated/>
#nullable enable
{{nsBlock}}partial class {{className}}
: global::System.ComponentModel.INotifyPropertyChanged
{
public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
{{properties}}
}
""";
// Include namespace in hint name to avoid collisions across namespaces
var hintPrefix = string.IsNullOrEmpty(ns) ? className : $"{ns}.{className}";
context.AddSource($"{hintPrefix}.AutoNotify.g.cs", source);
}
}
private static string GetPropertyName(string fieldName)
=> fieldName.TrimStart('_') is [var first, .. var rest]
? $"{char.ToUpperInvariant(first)}{rest}"
: fieldName;
}
internal readonly record struct FieldInfo(
string Namespace,
string ClassName,
string FullTypeName,
string FieldName,
string FieldType);
Scope note: This example targets top-level, non-generic classes for clarity. A production generator should also handle generic type parameters (emitting matching
partial class Foo<T>declarations) and nested types (emitting nested partial class hierarchies). Report a diagnostic for unsupported shapes rather than emitting invalid code.
ForAttributeWithMetadataName or CreateSyntaxProvider with a tight predicate to minimize work.ISymbol or SyntaxNode through the pipeline (they hold the compilation alive and break caching).record struct or implement IEquatable<T> for custom types.// <auto-generated/> and #nullable enable headers.// ForAttributeWithMetadataName -- most common, filters by attribute
var candidates = context.SyntaxProvider.ForAttributeWithMetadataName(
"MyLib.GenerateMapperAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) => /* extract info */);
// CreateSyntaxProvider -- general-purpose, any syntax predicate
var candidates = context.SyntaxProvider.CreateSyntaxProvider(
predicate: static (node, _) => node is MethodDeclarationSyntax m
&& m.Modifiers.Any(SyntaxKind.PartialKeyword),
transform: static (ctx, _) => /* extract info */);
Report errors and warnings through SourceProductionContext rather than throwing exceptions. To report location-specific diagnostics, include a Location in your pipeline data (captured from the syntax node in the transform step).
private static readonly DiagnosticDescriptor InvalidFieldType = new(
id: "AN001",
title: "Invalid field type for AutoNotify",
messageFormat: "Field '{0}' must be a non-pointer type",
category: "AutoNotify",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
// In the transform step, capture location:
var location = context.TargetNode.GetLocation();
// In the Execute method, report with location:
context.ReportDiagnostic(Diagnostic.Create(
InvalidFieldType,
location, // captured from syntax node, not from projected data
fieldName));
Note:
Locationis not value-equatable, so including it in your pipeline record breaks incremental caching. A common pattern is to carry it as a separate field that you exclude from equality, or report diagnostics in aCreateSyntaxProviderstep before projecting to value types.
// Prefer raw string literals for templates (C# 11+, in the generator project)
var source = $$"""
// <auto-generated/>
#nullable enable
namespace {{ns}};
partial class {{className}}
{
{{generatedMembers}}
}
""";
context.AddSource($"{className}.g.cs", source);
File naming convention: {TypeName}.{Feature}.g.cs -- the .g.cs suffix signals generated code and is excluded by many linters.
Use RegisterPostInitializationOutput for marker attributes and helper types that do not depend on user code:
context.RegisterPostInitializationOutput(static ctx =>
{
ctx.AddSource("AutoNotifyAttribute.g.cs", """
// <auto-generated/>
namespace MyLib;
[System.AttributeUsage(System.AttributeTargets.Field)]
internal sealed class AutoNotifyAttribute : System.Attribute { }
""");
});
Use CSharpGeneratorDriver to run generators in-memory and verify output.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
[Fact]
public void Generator_ProducesExpectedOutput()
{
// Arrange
var source = """
using MyLib;
namespace TestApp;
public partial class ViewModel
{
[AutoNotify]
private string _name = "";
}
""";
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location))
.Cast<MetadataReference>()
.ToList();
var compilation = CSharpCompilation.Create("TestAssembly",
[syntaxTree],
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var generator = new AutoNotifyGenerator();
// Act
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
driver = driver.RunGeneratorsAndUpdateCompilation(
compilation, out var outputCompilation, out var diagnostics);
// Assert
Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
var runResult = driver.GetRunResult();
Assert.Single(runResult.GeneratedTrees);
var generatedSource = runResult.GeneratedTrees[0].GetText().ToString();
Assert.Contains("public string Name", generatedSource);
}
For more robust testing, use the Verify.SourceGenerators package to snapshot-test generated output:
[Fact]
public Task Generator_SnapshotTest()
{
var source = """
using MyLib;
namespace TestApp;
public partial class ViewModel
{
[AutoNotify]
private string _name = "";
}
""";
return TestHelper.Verify(source);
}
[GeneratedRegex] (net7.0+)Compile-time regex generation. Zero runtime compilation cost, AOT-compatible.
public partial class Validators
{
[GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex EmailRegex();
public static bool IsValidEmail(string email)
=> EmailRegex().IsMatch(email);
}
Key rules:
static partial returning Regexpartial class (or partial struct)new Regex(...) with zero allocation at runtimeRegexOptions except RegexOptions.Compiled (which is ignored -- the source generator replaces it)[LoggerMessage] (net6.0+)High-performance structured logging with zero-allocation at log-disabled levels.
public static partial class LogMessages
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Processing order {OrderId} for customer {CustomerId}")]
public static partial void OrderProcessing(
this ILogger logger, int orderId, string customerId);
[LoggerMessage(Level = LogLevel.Error,
Message = "Failed to process order {OrderId}")]
public static partial void OrderProcessingFailed(
this ILogger logger, int orderId, Exception exception);
}
// Usage
logger.OrderProcessing(order.Id, order.CustomerId);
Key rules:
static partial in a partial class{Placeholder} in the message are logged as structured dataException parameter is logged automatically (do not include in message template)AOT-compatible JSON serialization. Eliminates runtime reflection.
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
[JsonSerializable(typeof(Customer))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class AppJsonContext : JsonSerializerContext;
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
// Or for Minimal APIs
app.MapGet("/orders/{id}", async (int id, IOrderService service) =>
{
var order = await service.GetByIdAsync(id);
return order is not null
? Results.Ok(order)
: Results.NotFound();
});
// Serialize
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
// Deserialize
var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
// With stream
await JsonSerializer.SerializeAsync(stream, orders,
AppJsonContext.Default.ListOrder);
Key rules:
[JsonSerializable] attributesTypeInfoResolverChain (net8.0+) to combine multiple contexts[JsonSerializable] with Polymorphism (net7.0+)[JsonDerivedType(typeof(CreditCardPayment), "credit")]
[JsonDerivedType(typeof(BankTransferPayment), "bank")]
public abstract class Payment
{
public decimal Amount { get; init; }
}
public class CreditCardPayment : Payment
{
public required string CardLast4 { get; init; }
}
public class BankTransferPayment : Payment
{
public required string AccountNumber { get; init; }
}
[JsonSerializable(typeof(Payment))]
public partial class PaymentJsonContext : JsonSerializerContext;
<ItemGroup>
<ProjectReference Include="..\MyGenerator\MyGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
When shipping a generator as a NuGet package, place the assembly under analyzers/dotnet/cs/:
MyGenerator.nupkg
analyzers/
dotnet/
cs/
MyGenerator.dll
lib/
netstandard2.0/
_._ (empty placeholder if no runtime dependency)
<!-- In the generator .csproj -->
<PropertyGroup>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs" />
</ItemGroup>
// Add to Initialize() for attach-debugger workflow
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
System.Diagnostics.Debugger.Launch();
}
#endif
Alternatively, emit generated files to disk for inspection:
<!-- In the consuming project -->
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Add Generated/ to .gitignore.
testing
Get best practices for TUnit unit testing, including data-driven tests
development
Severity scoring, scorecard computation, confidence levels, and remediation tracking for web accessibility audits. Use when computing page accessibility scores (0-100 with A-F grades), tracking remediation progress across audits, or generating cross-page comparison scorecards.
development
Web content discovery, URL crawling, and page inventory for accessibility audits. Use when scanning web pages, crawling sites for audit scope, or building page inventories for multi-page audits.
development
Audit report formatting, severity scoring, scorecard computation, and compliance export for document accessibility audits. Use when generating DOCUMENT-ACCESSIBILITY-AUDIT.md reports, computing document severity scores (0-100 with A-F grades), creating VPAT/ACR compliance exports, or formatting remediation priorities.