Add MergeUsageAnalyzer to detect legacy merge service usage
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented MergeUsageAnalyzer to flag usage of AdvisoryMergeService and AddMergeModule.
- Created AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md for release documentation.
- Added tests for MergeUsageAnalyzer to ensure correct diagnostics for various scenarios.
- Updated project files for analyzers and tests to include necessary dependencies and configurations.
- Introduced a sample report structure for scanner output.
This commit is contained in:
master
2025-11-06 15:03:39 +02:00
parent 5a923d968c
commit 950f238a93
45 changed files with 1291 additions and 623 deletions

View File

@@ -2,8 +2,4 @@
### Unreleased
#### New Rules
Rule ID | Title | Notes
--------|-------|------
CONCELIER0002 | Legacy merge pipeline is disabled | Flags usage of `AddMergeModule` and `AdvisoryMergeService`.
No analyzer rules currently scheduled for release.

View File

@@ -1,152 +0,0 @@
using System;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
namespace StellaOps.Concelier.Analyzers;
/// <summary>
/// Analyzer that flags usages of the legacy merge service APIs.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoMergeUsageAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// Diagnostic identifier for legacy merge usage violations.
/// </summary>
public const string DiagnosticId = "CONCELIER0002";
private const string Category = "Usage";
private const string MergeExtensionType = "StellaOps.Concelier.Merge.MergeServiceCollectionExtensions";
private const string MergeServiceType = "StellaOps.Concelier.Merge.Services.AdvisoryMergeService";
private static readonly LocalizableString Title = "Legacy merge pipeline is disabled";
private static readonly LocalizableString MessageFormat = "Do not reference the legacy Concelier merge pipeline (type '{0}')";
private static readonly LocalizableString Description =
"The legacy Concelier merge service is deprecated under MERGE-LNM-21-002. "
+ "Switch to observation/linkset APIs or guard calls behind the concelier:features:noMergeEnabled toggle.";
private static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
Title,
MessageFormat,
Category,
DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: Description,
helpLinkUri: "https://stella-ops.org/docs/migration/no-merge");
/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
/// <inheritdoc />
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation);
}
private static void AnalyzeInvocation(OperationAnalysisContext context)
{
if (context.Operation is not IInvocationOperation invocation)
{
return;
}
var targetMethod = invocation.TargetMethod;
if (targetMethod is null)
{
return;
}
if (!SymbolEquals(targetMethod.ContainingType, MergeExtensionType))
{
return;
}
if (!string.Equals(targetMethod.Name, "AddMergeModule", StringComparison.Ordinal))
{
return;
}
if (IsAllowedAssembly(context.ContainingSymbol.ContainingAssembly))
{
return;
}
ReportDiagnostic(context, invocation.Syntax, $"{MergeExtensionType}.{targetMethod.Name}");
}
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
{
if (context.Operation is not IObjectCreationOperation creation)
{
return;
}
var createdType = creation.Type;
if (createdType is null || !SymbolEquals(createdType, MergeServiceType))
{
return;
}
if (IsAllowedAssembly(context.ContainingSymbol.ContainingAssembly))
{
return;
}
ReportDiagnostic(context, creation.Syntax, MergeServiceType);
}
private static bool SymbolEquals(ITypeSymbol? symbol, string fullName)
{
if (symbol is null)
{
return false;
}
var display = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (display.StartsWith("global::", StringComparison.Ordinal))
{
display = display.Substring("global::".Length);
}
return string.Equals(display, fullName, StringComparison.Ordinal);
}
private static bool IsAllowedAssembly(IAssemblySymbol? assemblySymbol)
{
if (assemblySymbol is null)
{
return false;
}
var assemblyName = assemblySymbol.Name;
if (string.IsNullOrWhiteSpace(assemblyName))
{
return false;
}
if (assemblyName.StartsWith("StellaOps.Concelier.Merge", StringComparison.Ordinal))
{
return true;
}
if (assemblyName.EndsWith(".Analyzers", StringComparison.Ordinal))
{
return true;
}
return false;
}
private static void ReportDiagnostic(OperationAnalysisContext context, SyntaxNode syntax, string targetName)
{
var diagnostic = Diagnostic.Create(Rule, syntax.GetLocation(), targetName);
context.ReportDiagnostic(diagnostic);
}
}

View File

@@ -0,0 +1,2 @@
; Shipped analyzer releases

View File

@@ -0,0 +1,9 @@
## Release History
### Unreleased
#### New Rules
Rule ID | Title | Notes
--------|-------|------
CONCELIER0002 | Legacy merge service usage detected | Flags references to `AdvisoryMergeService` and `AddMergeModule`.

View File

@@ -0,0 +1,237 @@
using System;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
namespace StellaOps.Concelier.Merge.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class MergeUsageAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "CONCELIER0002";
private const string AdvisoryMergeServiceTypeName = "StellaOps.Concelier.Merge.Services.AdvisoryMergeService";
private const string MergeExtensionsTypeName = "StellaOps.Concelier.Merge.MergeServiceCollectionExtensions";
private const string AddMergeModuleMethodName = "AddMergeModule";
private static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
title: "Legacy merge service usage detected",
messageFormat: "Advisory merge pipeline is deprecated; remove usage of '{0}' and adopt Link-Not-Merge linkset workflows (MERGE-LNM-21-002)",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Link-Not-Merge replaces the legacy AdvisoryMergeService. Set concelier:features:noMergeEnabled=true and migrate to observation/linkset APIs instead of invoking merge services directly.",
helpLinkUri: "https://stella-ops.org/docs/migration/no-merge");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation);
context.RegisterOperationAction(AnalyzeTypeOf, OperationKind.TypeOf);
context.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName);
}
private static void AnalyzeInvocation(OperationAnalysisContext context)
{
if (context.Operation is not IInvocationOperation invocation)
{
return;
}
var method = invocation.TargetMethod;
if (!IsAddMergeModule(method) && (method.ReducedFrom is null || !IsAddMergeModule(method.ReducedFrom)))
{
return;
}
if (IsAllowedAssembly(method.ContainingAssembly, context.ContainingSymbol))
{
return;
}
ReportDiagnostic(context, invocation.Syntax.GetLocation(), $"{method.ContainingType.Name}.{method.Name}");
}
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
{
if (context.Operation is not IObjectCreationOperation creation)
{
return;
}
if (creation.Type is not INamedTypeSymbol type || !IsAdvisoryMergeService(type))
{
return;
}
if (IsAllowedAssembly(type.ContainingAssembly, context.ContainingSymbol))
{
return;
}
ReportDiagnostic(context, creation.Syntax.GetLocation(), type.Name);
}
private static void AnalyzeTypeOf(OperationAnalysisContext context)
{
if (context.Operation is not ITypeOfOperation typeOfOperation)
{
return;
}
if (typeOfOperation.TypeOperand is not INamedTypeSymbol type || !IsAdvisoryMergeService(type))
{
return;
}
if (IsAllowedAssembly(type.ContainingAssembly, context.ContainingSymbol))
{
return;
}
ReportDiagnostic(context, typeOfOperation.Syntax.GetLocation(), type.Name);
}
private static void AnalyzeIdentifier(SyntaxNodeAnalysisContext context)
{
var identifier = (IdentifierNameSyntax)context.Node;
if (!IsRightMostIdentifier(identifier))
{
return;
}
var symbolInfo = context.SemanticModel.GetSymbolInfo(identifier, context.CancellationToken);
var symbol = symbolInfo.Symbol;
if (symbol is not INamedTypeSymbol typeSymbol || !IsAdvisoryMergeService(typeSymbol))
{
return;
}
if (IsAllowedAssembly(typeSymbol.ContainingAssembly, context.ContainingSymbol))
{
return;
}
if (IsPartOfSuppressedConstruct(identifier))
{
return;
}
var diagnostic = Diagnostic.Create(Rule, identifier.GetLocation(), typeSymbol.Name);
context.ReportDiagnostic(diagnostic);
}
private static bool IsPartOfSuppressedConstruct(IdentifierNameSyntax identifier)
{
foreach (var ancestor in identifier.Ancestors())
{
switch (ancestor)
{
case ObjectCreationExpressionSyntax:
case TypeOfExpressionSyntax:
return true;
}
}
return false;
}
private static bool IsRightMostIdentifier(IdentifierNameSyntax identifier)
{
if (identifier.Parent is QualifiedNameSyntax qualified)
{
return qualified.Right == identifier;
}
if (identifier.Parent is AliasQualifiedNameSyntax aliasQualified)
{
return aliasQualified.Name == identifier;
}
return true;
}
private static bool IsAddMergeModule(IMethodSymbol methodSymbol)
{
if (!string.Equals(methodSymbol.Name, AddMergeModuleMethodName, StringComparison.Ordinal))
{
return false;
}
var containingType = methodSymbol.ContainingType;
if (containingType is null)
{
return false;
}
var display = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
display = TrimGlobalPrefix(display);
return string.Equals(display, MergeExtensionsTypeName, StringComparison.Ordinal);
}
private static bool IsAdvisoryMergeService(INamedTypeSymbol symbol)
{
var display = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
display = TrimGlobalPrefix(display);
return string.Equals(display, AdvisoryMergeServiceTypeName, StringComparison.Ordinal);
}
private static void ReportDiagnostic(OperationAnalysisContext context, Location location, string target)
{
var diagnostic = Diagnostic.Create(Rule, location, target);
context.ReportDiagnostic(diagnostic);
}
private static bool IsAllowedAssembly(IAssemblySymbol? referencedAssembly, ISymbol? containingSymbol)
{
var consumerAssembly = containingSymbol?.ContainingAssembly;
if (referencedAssembly is null || consumerAssembly is null)
{
return false;
}
var referencedName = referencedAssembly.Name;
if (!string.IsNullOrWhiteSpace(referencedName) &&
referencedName.StartsWith("StellaOps.Concelier.Merge", StringComparison.Ordinal))
{
return true;
}
var name = consumerAssembly.Name;
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
if (name.StartsWith("StellaOps.Concelier.Merge", StringComparison.Ordinal))
{
return true;
}
if (name.EndsWith(".Analyzers", StringComparison.Ordinal))
{
return true;
}
return false;
}
private static string TrimGlobalPrefix(string display)
{
if (!display.StartsWith("global::", StringComparison.Ordinal))
{
return display;
}
return display.Substring("global::".Length);
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IncludeBuildOutput>false</IncludeBuildOutput>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Visible="false" />
<None Include="AnalyzerReleases.Shipped.md" Visible="false" />
<None Include="AnalyzerReleases.Unshipped.md" Visible="false" />
</ItemGroup>
</Project>