feat: implement analyzer and code fixer for identifying and fixing when a sync flag is not propagated correctly.

This commit is contained in:
Stevan Freeborn
2026-06-18 21:28:41 -05:00
parent 86b9673b24
commit f3da2f60fd
45 changed files with 2184 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<PackageProjectUrl>https://gitea.freeborn.cloud/Stevan/stevanfreeborn.asyncsyncflaganalyzer</PackageProjectUrl>
<RepositoryUrl>https://gitea.freeborn.cloud/Stevan/stevanfreeborn.asyncsyncflaganalyzer</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.Gitea">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all" />
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.300" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StevanFreeborn.AsyncSyncFlagAnalyzer.Common\StevanFreeborn.AsyncSyncFlagAnalyzer.Common.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,138 @@
using System.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using StevanFreeborn.AsyncSyncFlagAnalyzer.Common;
namespace StevanFreeborn.AsyncSyncFlagAnalyzer.CodeFixes;
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SyncParameterCodeFixProvider)), Shared]
public class SyncParameterCodeFixProvider : CodeFixProvider
{
public sealed override ImmutableArray<string> FixableDiagnosticIds => [DiagnosticProperties.DiagnosticId];
public sealed override FixAllProvider GetFixAllProvider()
{
return WellKnownFixAllProviders.BatchFixer;
}
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document
.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
var diagnostic = context.Diagnostics.FirstOrDefault();
if (diagnostic is null)
{
return;
}
var diagnosticSpan = diagnostic.Location.SourceSpan;
var invocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf()
.OfType<InvocationExpressionSyntax>()
.FirstOrDefault();
if (invocation is null)
{
return;
}
_ = diagnostic.Properties.TryGetValue(DiagnosticProperties.EnclosingSyncName, out var enclosingSyncName);
if (string.IsNullOrEmpty(enclosingSyncName))
{
enclosingSyncName = DiagnosticProperties.DefaultSyncName;
}
_ = diagnostic.Properties.TryGetValue(DiagnosticProperties.TargetSyncName, out var targetSyncName);
if (string.IsNullOrEmpty(targetSyncName))
{
targetSyncName = enclosingSyncName;
}
_ = diagnostic.Properties.TryGetValue(DiagnosticProperties.TargetSyncOrdinal, out var targetSyncOrdinalStr);
if (!int.TryParse(targetSyncOrdinalStr, out var targetSyncOrdinal))
{
targetSyncOrdinal = -1;
}
var codeAction = CodeAction.Create(
title: $"Pass '{enclosingSyncName}' parameter",
createChangedDocument: c => AddSyncParameterAsync(
context.Document,
invocation,
enclosingSyncName!,
targetSyncName!,
targetSyncOrdinal,
c
),
equivalenceKey: "PassSyncParameterFix"
);
context.RegisterCodeFix(codeAction, diagnostic);
}
private static async Task<Document> AddSyncParameterAsync(
Document document,
InvocationExpressionSyntax invocation,
string enclosingSyncName,
string targetSyncName,
int targetSyncOrdinal,
CancellationToken cancellationToken
)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null)
{
return document;
}
var arguments = invocation.ArgumentList.Arguments;
var syncIdentifier = SyntaxFactory.IdentifierName(enclosingSyncName);
var existingNamedArg = arguments.FirstOrDefault(
a => a.NameColon?.Name.Identifier.Text == targetSyncName
);
if (existingNamedArg is not null)
{
var newNamedArg = existingNamedArg.WithExpression(syncIdentifier);
var newArguments = arguments.Replace(existingNamedArg, newNamedArg);
var newInvocation = invocation.WithArgumentList(invocation.ArgumentList.WithArguments(newArguments));
var newRoot = root.ReplaceNode(invocation, newInvocation);
return document.WithSyntaxRoot(newRoot);
}
var positionalCount = 0;
foreach (var arg in arguments)
{
if (arg.NameColon is not null)
{
break;
}
positionalCount++;
}
var newArg = SyntaxFactory.Argument(syncIdentifier);
var updatedArguments = targetSyncOrdinal >= positionalCount
? arguments.Insert(positionalCount, newArg)
: arguments.Replace(arguments[targetSyncOrdinal], newArg);
var resultInvocation = invocation.WithArgumentList(invocation.ArgumentList.WithArguments(updatedArguments));
var resultRoot = root.ReplaceNode(invocation, resultInvocation);
return document.WithSyntaxRoot(resultRoot);
}
}
@@ -0,0 +1,11 @@
namespace StevanFreeborn.AsyncSyncFlagAnalyzer.Common;
public static class DiagnosticProperties
{
public const string DiagnosticId = "SYNC001";
public const string DefaultSyncName = "sync";
public const string EditorConfigKey = "dotnet_diagnostic.SYNC001.additional_sync_names";
public const string EnclosingSyncName = "EnclosingSyncName";
public const string TargetSyncName = "TargetSyncName";
public const string TargetSyncOrdinal = "TargetSyncOrdinal";
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
@@ -0,0 +1,7 @@
## Release 0.0
### New Rules
Rule ID | Category | Severity | Notes
--------|--------------|----------|----------------------
SYNC001 | Architecture | Error | SyncParameterAnalyzer
@@ -0,0 +1,52 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>true</IsPackable>
<PackageId>StevanFreeborn.AsyncSyncFlagAnalyzer</PackageId>
<Version>0.0.0</Version>
<Authors>Stevan Freeborn</Authors>
<Description>Roslyn analyzer to enforce passing the sync flag in optionally asynchronous execution paths.</Description>
<PackageTags>analyzer,async,sync,flag</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<DevelopmentDependency>true</DevelopmentDependency>
<IncludeBuildOutput>false</IncludeBuildOutput>
<NoPackageAnalysis>true</NoPackageAnalysis>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<PropertyGroup Condition="'$(GITEA_ACTIONS)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StevanFreeborn.AsyncSyncFlagAnalyzer.CodeFixes\StevanFreeborn.AsyncSyncFlagAnalyzer.CodeFixes.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\StevanFreeborn.AsyncSyncFlagAnalyzer.Common\StevanFreeborn.AsyncSyncFlagAnalyzer.Common.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<Target Name="IncludeCodeFixInPackage" BeforeTargets="GenerateNuspec" DependsOnTargets="Build">
<ItemGroup>
<_PackageFiles Include="$(MSBuildProjectDirectory)\..\$(MSBuildProjectName).CodeFixes\bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).CodeFixes.dll">
<PackagePath>analyzers/dotnet/cs</PackagePath>
<Visible>false</Visible>
<BuildAction>None</BuildAction>
</_PackageFiles>
</ItemGroup>
</Target>
</Project>
@@ -0,0 +1,130 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using StevanFreeborn.AsyncSyncFlagAnalyzer.Common;
namespace StevanFreeborn.AsyncSyncFlagAnalyzer;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SyncParameterAnalyzer : DiagnosticAnalyzer
{
private const string Title = "Missing sync parameter in optionally async call";
private const string MessageFormat = "The method '{0}' must pass the '{1}' parameter to '{2}'";
private static readonly DiagnosticDescriptor Rule = new(
DiagnosticProperties.DiagnosticId,
Title,
MessageFormat,
"Architecture",
DiagnosticSeverity.Error,
isEnabledByDefault: true
);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
public override void Initialize(AnalysisContext context)
{
if (context is null)
{
return;
}
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeAwaitExpression, SyntaxKind.AwaitExpression);
}
private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context)
{
var awaitExpr = (AwaitExpressionSyntax)context.Node;
var enclosingMethod = awaitExpr.FirstAncestorOrSelf<MethodDeclarationSyntax>();
if (enclosingMethod is null)
{
return;
}
var validSyncNames = GetValidSyncNames(context);
var enclosingSyncParam = enclosingMethod.ParameterList.Parameters
.FirstOrDefault(p => validSyncNames.Contains(p.Identifier.Text));
if (enclosingSyncParam is null)
{
return;
}
var expectedSyncName = enclosingSyncParam.Identifier.Text;
if (awaitExpr.Expression is not InvocationExpressionSyntax invocation)
{
return;
}
if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol methodSymbol)
{
return;
}
var targetSyncParam = methodSymbol.Parameters.FirstOrDefault(p => validSyncNames.Contains(p.Name));
if (targetSyncParam is null)
{
return;
}
var passedSync = false;
foreach (var argument in invocation.ArgumentList.Arguments)
{
if (argument.Expression is IdentifierNameSyntax id && id.Identifier.Text == expectedSyncName)
{
passedSync = true;
break;
}
}
if (!passedSync)
{
var properties = ImmutableDictionary<string, string?>.Empty
.Add(DiagnosticProperties.EnclosingSyncName, enclosingSyncParam.Identifier.Text)
.Add(DiagnosticProperties.TargetSyncName, targetSyncParam.Name)
.Add(DiagnosticProperties.TargetSyncOrdinal, targetSyncParam.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture));
var diagnostic = Diagnostic.Create(
Rule,
invocation.GetLocation(),
properties,
enclosingMethod.Identifier.Text,
expectedSyncName,
methodSymbol.Name
);
context.ReportDiagnostic(diagnostic);
}
}
private static HashSet<string> GetValidSyncNames(SyntaxNodeAnalysisContext context)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { DiagnosticProperties.DefaultSyncName };
var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree);
if (options.TryGetValue(DiagnosticProperties.EditorConfigKey, out var customNames) && !string.IsNullOrWhiteSpace(customNames))
{
var splitNames = customNames.Split([','], StringSplitOptions.RemoveEmptyEntries);
foreach (var name in splitNames)
{
_ = names.Add(name.Trim());
}
}
return names;
}
}