# Revit Toolkit Analyzers

> Author and extend the Roslyn tooling bundled in the Nice3point.Revit.Toolkit package: the incremental source generator that emits external-event boilerplate, the analyzers and code fixers that enforce its prerequisites, the RVTTK#### diagnostic catalog, and the dual-Roslyn packaging. USE FOR: adding or changing a generator, analyzer, code fixer, or diagnostic in the Analyzers, CodeFixers, or SourceGenerators projects; assigning a diagnostic id; wiring the .Roslyn### twin projects and Directory.Roslyn.props; packaging the tooling into the analyzers/dotnet/roslyn paths; recording a rule in AnalyzerReleases. DO NOT USE FOR: designing the runtime library types the generator emits against, such as the external-event classes and static contexts.

- Skill: `nice3point/revit-toolkit-analyzers` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nice3point/revit-toolkit-analyzers`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nice3point/revit-toolkit-analyzers/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: nice3point (https://skillmd.com/u/nice3point)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nice3point/revit-toolkit-analyzers

---


# Revit Toolkit Analyzers

Nice3point.Revit.Toolkit ships Roslyn tooling inside the NuGet package: an incremental source generator that turns an `[ExternalEvent]`-annotated method into event properties, analyzers that enforce the annotation's prerequisites, and code fixers that resolve the diagnostics.
The generated API shape and the `RVTTK####` diagnostic ids are public surface; treat every id and generated name as a contract.
This skill covers authoring and extending that tooling; the generator and analyzers require `Microsoft.CodeAnalysis.CSharp`.

## When to use

- Adding or changing the `ExternalEventGenerator` incremental generator or its `Extractor`/`Writer` partials.
- Adding a diagnostic to the `DiagnosticDescriptors` catalog or a new analyzer or code fixer.
- Wiring a `.Roslyn###` twin project or editing `Directory.Roslyn.props`.
- Changing how the tooling assemblies pack into the `analyzers/dotnet/roslyn{4.14,5.0}` paths.

## When not to use

- Designing the runtime types the generator emits against (the external-event family, the static contexts). That is the library design contract, not the compiler tooling.

## Workflow

### Step 1: Emit from the incremental generator

`ExternalEventGenerator` is `[Generator(LanguageNames.CSharp)]` and implements `IIncrementalGenerator`.
It pipes matched methods with `ForAttributeWithMetadataName`, reports diagnostics, then registers source output.
Keep the pipeline incremental: extract into an equatable model in `Extractor`, and render text in `Writer`.

```csharp
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    var results = context.SyntaxProvider
        .ForAttributeWithMetadataName(
            WellKnownFullyQualifiedClassNames.ExternalEventAttribute.WithoutGlobalPrefix,
            predicate: static (node, cancellationToken) => node is MethodDeclarationSyntax,
            transform: static (syntaxContext, cancellationToken) => Extractor.GetMethodResult(syntaxContext, cancellationToken));

    context.ReportDiagnostics(results.Select(static (result, cancellationToken) => result.Diagnostics));

    var infosWithOptions = results
        .Where(static result => result.Info is not null)
        .Select(static (result, cancellationToken) => result.Info!)
        .Combine(context.ParseOptionsProvider);

    context.RegisterSourceOutput(infosWithOptions, static (sourceProductionContext, pair) =>
    {
        var (info, parseOptions) = pair;
        var source = Writer.GenerateSource(info, useFieldKeyword: false);
        sourceProductionContext.AddSource(info.HintName, SourceText.From(source, Encoding.UTF8));
    });
}
```

Emit into the method's own namespace and a stable hint name derived from the type hierarchy, and treat every generated member name as public surface.
Never carry a `SyntaxNode`, `ISymbol`, or `Compilation` into the model; carry only equatable data. The pipeline then caches correctly.

### Step 2: Register the diagnostic in the RVTTK catalog

Diagnostics live in the internal `DiagnosticDescriptors` catalog under the `ExternalEventGenerator` category, keyed by a `RVTTK####` id.
The shipped ids are `RVTTK0001` (returns `Task`), `RVTTK0002` (`async void`), `RVTTK0003` (generic), `RVTTK0004` (duplicate overloads), and `RVTTK0005` (containing type not partial).
Assign the next unused id, never reuse a retired one, and give it a title, a parameterized `messageFormat`, a category, and a severity.

```csharp
public static readonly DiagnosticDescriptor ExternalEventContainingTypeNotPartial = new(
    id: "RVTTK0005",
    title: "Containing type is not partial",
    messageFormat: "The type '{0}' containing method '{1}' marked with [ExternalEvent] must be declared as partial",
    category: "ExternalEventGenerator",
    defaultSeverity: DiagnosticSeverity.Error,
    isEnabledByDefault: true);
```

### Step 3: Report from the generator or a dedicated analyzer

Choose the reporter by whether the check needs the full symbol model at edit time.
The generator's `Extractor.ValidateMethod` reports `RVTTK0001`, `RVTTK0003`, and `RVTTK0004` while building the model, returning `false` to skip emission.
`RVTTK0002` and `RVTTK0005` also have standalone analyzers (`AsyncVoidMethodAnalyzer`, `ExternalEventContainingTypeNotPartialAnalyzer`); the IDE flags them live and a code fixer can attach.

An analyzer resolves the attribute symbol once per compilation, then registers a symbol action.

```csharp
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class AsyncVoidMethodAnalyzer : DiagnosticAnalyzer
{
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = [DiagnosticDescriptors.ExternalEventAsyncVoidMethod];

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterCompilationStartAction(static context =>
        {
            var attributeSymbol = context.Compilation.GetTypeByMetadataName("Nice3point.Revit.Toolkit.External.ExternalEventAttribute");
            if (attributeSymbol is null)
            {
                return;
            }

            context.RegisterSymbolAction(context =>
            {
                if (context.Symbol is not IMethodSymbol { IsAsync: true, ReturnsVoid: true } methodSymbol)
                {
                    return;
                }

                if (!HasTargetAttribute(methodSymbol, attributeSymbol))
                {
                    return;
                }

                context.ReportDiagnostic(Diagnostic.Create(
                    descriptor: DiagnosticDescriptors.ExternalEventAsyncVoidMethod,
                    location: methodSymbol.Locations[0],
                    messageArgs: methodSymbol.Name));
            }, SymbolKind.Method);
        });
    }
}
```

Always call `ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None)` and `EnableConcurrentExecution()`, and bail out early when the attribute type is absent from the compilation.

### Step 4: Add a code fixer when the fix is mechanical

Add a fixer only when the violation resolves without a human decision.
A fixer is `[Shared]` and `[ExportCodeFixProvider(LanguageNames.CSharp)]`, lists the fixable id from the catalog, and returns `WellKnownFixAllProviders.BatchFixer`.

```csharp
[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public sealed class MakeTypePartialCodeFixer : CodeFixProvider
{
    private const string Title = "Make type partial";

    public override ImmutableArray<string> FixableDiagnosticIds { get; } = [DiagnosticDescriptors.ExternalEventContainingTypeNotPartial.Id];

    public override FixAllProvider GetFixAllProvider()
    {
        return WellKnownFixAllProviders.BatchFixer;
    }

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var diagnostic = context.Diagnostics[0];
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
        var typeDeclaration = root!.FindNode(context.Span).FirstAncestorOrSelf<TypeDeclarationSyntax>();
        if (typeDeclaration is null) return;

        context.RegisterCodeFix(
            CodeAction.Create(
                title: Title,
                createChangedDocument: cancellationToken => AddPartialModifier(context.Document, root, typeDeclaration, cancellationToken),
                equivalenceKey: Title),
            diagnostic);
    }
}
```

Reference the fixable id through the catalog (`DiagnosticDescriptors.X.Id`), never a string literal; an id can never drift between analyzer and fixer.

### Step 5: Compile the same source against both Roslyn versions

Every tooling project has a `.Roslyn###` twin that compiles the same source against an older Roslyn; the package works on older IDEs and SDKs.
`Directory.Roslyn.props` parses the suffix, links the base project's `.cs` files into the twin, and sets `ROSLYN*_OR_GREATER` constants.
Author the code once in the base project; the twin's csproj is empty except for the props import and a version-pinned `Microsoft.CodeAnalysis.CSharp`.

```xml
<!-- Twin project: Nice3point.Revit.Toolkit.Analyzers.Roslyn414.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
    <Import Project="..\..\Directory.Roslyn.props"/>
    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" VersionOverride="$(RoslynVersion).*" PrivateAssets="all"/>
    </ItemGroup>
</Project>
```

Guard any API that differs across Roslyn versions with the `ROSLYN5_0_0_OR_GREATER` / `ROSLYN4_14_0_OR_GREATER` constants, as `ExternalEventGenerator` does when it decides whether the `field` keyword is available.
Never copy source into a twin; `Directory.Roslyn.props` links it automatically via `<Compile Include="..\$(_BaseProjectName)\**\*.cs" .../>`.

### Step 6: Package into the version-specific analyzer paths

The runtime library project is the only package-producing project.
It packs each tooling assembly into the matching Roslyn path; the right build loads per IDE and SDK. It references the tooling projects with `ReferenceOutputAssembly="false"` purely for build order.

```xml
<None Include="..\Nice3point.Revit.Toolkit.SourceGenerators\bin\$(Configuration)\netstandard2.0\Nice3point.Revit.Toolkit.SourceGenerators.dll" PackagePath="analyzers\dotnet\roslyn5.0\cs" Pack="true" Visible="false"/>
<None Include="..\Nice3point.Revit.Toolkit.SourceGenerators.Roslyn414\bin\$(Configuration)\netstandard2.0\Nice3point.Revit.Toolkit.SourceGenerators.dll" PackagePath="analyzers\dotnet\roslyn4.14\cs" Pack="true" Visible="false"/>
```

All tooling projects target `netstandard2.0` with `IsRoslynComponent=true` and `EnforceExtendedAnalyzerRules=true`, set in `Directory.Roslyn.props`.

### Step 7: Record the rule, then test

Add every new or changed rule to `AnalyzerReleases.Unshipped.md` in the analyzer project; the release-tracking analyzer fails the build otherwise.
The shipped ids are already in `AnalyzerReleases.Shipped.md`.

```text
### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
RVTTK0006 | ExternalEventGenerator | Warning | <the new rule>
```

Then cover the change in the tooling test projects and build.
Analyzer and fixer tests assert both the reported `RVTTK####` and the fixed source; generator tests drive the generator and verify the emitted source and diagnostics.

```shell
dotnet run -c Release
```

## Validation

- [ ] The generator stays incremental: only equatable data crosses into the model, and output goes to a stable hint name and namespace.
- [ ] A new diagnostic uses the next unused `RVTTK####` id in `DiagnosticDescriptors`, with a title, parameterized message, category, and severity.
- [ ] Every analyzer configures generated-code analysis and concurrent execution and bails when the attribute type is absent.
- [ ] A code fixer is added only for a mechanical fix, is `[Shared]`/`[ExportCodeFixProvider]`, and references the fixable id through the catalog.
- [ ] Roslyn-version-specific APIs are guarded with `ROSLYN*_OR_GREATER`; no source is duplicated into a `.Roslyn###` twin.
- [ ] Each tooling assembly packs into both `analyzers/dotnet/roslyn5.0/cs` and `analyzers/dotnet/roslyn4.14/cs`.
- [ ] Every new or changed rule has an `AnalyzerReleases.Unshipped.md` entry and a test in the matching tooling test project.

## Common Pitfalls

| Pitfall                                                    | Correct approach                                                         |
|------------------------------------------------------------|--------------------------------------------------------------------------|
| Reusing or renumbering a retired `RVTTK####` id            | Assign the next unused id; shipped ids are public surface.               |
| Renaming a generated property or namespace                 | Generated names are public surface; deprecate, do not rename.            |
| Capturing a symbol or `Compilation` in the generator model | Carry only equatable data; the incremental pipeline then caches.         |
| Duplicating source into a `.Roslyn###` twin                | Author once in the base project; the props file links the files.         |
| A hard-coded diagnostic string in a code fixer             | Reference `DiagnosticDescriptors.X.Id`; analyzer and fixer stay in sync. |
| Skipping the `AnalyzerReleases.Unshipped.md` entry         | Add every rule; release tracking fails the build without it.             |
| Adding a code fixer for a fix that needs a human decision  | Report the diagnostic only; fix mechanically resolvable cases.           |

