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
@@ -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;
}
}