144 lines
7.8 KiB
C#
144 lines
7.8 KiB
C#
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.CodeAnalysis.Diagnostics;
|
|
using Microsoft.CodeAnalysis.Operations;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Analyzers;
|
|
|
|
/// <summary>
|
|
/// Diagnostic analyzer that flags direct invocations of Phase 6.1-wrapped driver-capability
|
|
/// methods when the call is NOT already running inside a <c>CapabilityInvoker.ExecuteAsync</c>,
|
|
/// <c>CapabilityInvoker.ExecuteWriteAsync</c>, or <c>AlarmSurfaceInvoker.*Async</c> lambda.
|
|
/// The wrapping is what gives us per-host breaker isolation, retry semantics, bulkhead-depth
|
|
/// accounting, and alarm-ack idempotence guards — raw calls bypass all of that.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The analyzer matches by receiver-interface identity using Roslyn's semantic model, not by
|
|
/// method name, so a driver with an unusually-named method implementing <c>IReadable.ReadAsync</c>
|
|
/// still trips the rule. Lambda-context detection walks up the syntax tree from the call site
|
|
/// + checks whether any enclosing <c>InvocationExpressionSyntax</c> targets a member whose
|
|
/// containing type is <c>CapabilityInvoker</c> or <c>AlarmSurfaceInvoker</c>. The rule is
|
|
/// intentionally narrow: it does NOT try to enforce the capability argument matches the
|
|
/// method (e.g. ReadAsync wrapped in <c>ExecuteAsync(DriverCapability.Write, ...)</c> still
|
|
/// passes) — that'd require flow analysis beyond single-expression scope.
|
|
/// </remarks>
|
|
[DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
|
public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
|
{
|
|
public const string DiagnosticId = "OTOPCUA0001";
|
|
|
|
/// <summary>Interfaces whose methods must be called through the capability invoker.</summary>
|
|
private static readonly string[] GuardedInterfaces =
|
|
[
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IWritable",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ITagDiscovery",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ISubscribable",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHostConnectivityProbe",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmSource",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider",
|
|
];
|
|
|
|
/// <summary>Wrapper types whose lambda arguments are the allowed home for guarded calls.</summary>
|
|
private static readonly string[] WrapperTypes =
|
|
[
|
|
"ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker",
|
|
];
|
|
|
|
private static readonly DiagnosticDescriptor Rule = new(
|
|
id: DiagnosticId,
|
|
title: "Driver capability call must be wrapped in CapabilityInvoker",
|
|
messageFormat: "Call to '{0}' is not wrapped in CapabilityInvoker.ExecuteAsync / ExecuteWriteAsync / AlarmSurfaceInvoker.*. Without the wrapping, Phase 6.1 resilience (retry, breaker, bulkhead, tracker telemetry) is bypassed for this call.",
|
|
category: "OtOpcUa.Resilience",
|
|
defaultSeverity: DiagnosticSeverity.Warning,
|
|
isEnabledByDefault: true,
|
|
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource/IHistoryProvider call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
|
|
|
|
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
|
|
|
|
public override void Initialize(AnalysisContext context)
|
|
{
|
|
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
|
context.EnableConcurrentExecution();
|
|
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
|
}
|
|
|
|
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
|
{
|
|
var invocation = (Microsoft.CodeAnalysis.Operations.IInvocationOperation)context.Operation;
|
|
var method = invocation.TargetMethod;
|
|
|
|
// Narrow the rule to async wire calls. Synchronous accessors like
|
|
// IHostConnectivityProbe.GetHostStatuses() are pure in-memory snapshots + would never
|
|
// benefit from the Polly pipeline; flagging them just creates false-positives.
|
|
if (!IsAsyncReturningType(method.ReturnType)) return;
|
|
if (!ImplementsGuardedInterface(method)) return;
|
|
if (IsInsideWrapperLambda(invocation.Syntax, context.Operation.SemanticModel, context.CancellationToken)) return;
|
|
|
|
var diag = Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), $"{method.ContainingType.Name}.{method.Name}");
|
|
context.ReportDiagnostic(diag);
|
|
}
|
|
|
|
private static bool IsAsyncReturningType(ITypeSymbol type)
|
|
{
|
|
var name = type.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
|
return name is "global::System.Threading.Tasks.Task"
|
|
or "global::System.Threading.Tasks.Task<TResult>"
|
|
or "global::System.Threading.Tasks.ValueTask"
|
|
or "global::System.Threading.Tasks.ValueTask<TResult>";
|
|
}
|
|
|
|
private static bool ImplementsGuardedInterface(IMethodSymbol method)
|
|
{
|
|
foreach (var iface in method.ContainingType.AllInterfaces.Concat(new[] { method.ContainingType }))
|
|
{
|
|
var ifaceFqn = iface.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
|
.Replace("global::", string.Empty);
|
|
if (!GuardedInterfaces.Contains(ifaceFqn)) continue;
|
|
|
|
foreach (var member in iface.GetMembers().OfType<IMethodSymbol>())
|
|
{
|
|
var impl = method.ContainingType.FindImplementationForInterfaceMember(member);
|
|
if (SymbolEqualityComparer.Default.Equals(impl, method) ||
|
|
SymbolEqualityComparer.Default.Equals(method.OriginalDefinition, member))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static bool IsInsideWrapperLambda(SyntaxNode startNode, SemanticModel? semanticModel, System.Threading.CancellationToken ct)
|
|
{
|
|
if (semanticModel is null) return false;
|
|
|
|
for (var node = startNode.Parent; node is not null; node = node.Parent)
|
|
{
|
|
// We only care about an enclosing invocation — the call we're auditing must literally
|
|
// live inside a lambda (ParenthesizedLambda / SimpleLambda / AnonymousMethod) that is
|
|
// an argument of a CapabilityInvoker.Execute* / AlarmSurfaceInvoker.* call.
|
|
if (node is not InvocationExpressionSyntax outer) continue;
|
|
|
|
var sym = semanticModel.GetSymbolInfo(outer, ct).Symbol as IMethodSymbol;
|
|
if (sym is null) continue;
|
|
|
|
var outerTypeFqn = sym.ContainingType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
|
.Replace("global::", string.Empty);
|
|
if (!WrapperTypes.Contains(outerTypeFqn)) continue;
|
|
|
|
// The call is wrapped IFF our startNode is transitively inside one of the outer
|
|
// call's argument lambdas. Walk the outer invocation's argument list + check whether
|
|
// any lambda body contains the startNode's position.
|
|
foreach (var arg in outer.ArgumentList.Arguments)
|
|
{
|
|
if (arg.Expression is not AnonymousFunctionExpressionSyntax lambda) continue;
|
|
if (lambda.Span.Contains(startNode.Span)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|