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;
///
/// Diagnostic analyzer that flags direct invocations of Phase 6.1-wrapped driver-capability
/// methods when the call is NOT already running inside a CapabilityInvoker.ExecuteAsync
/// or CapabilityInvoker.ExecuteWriteAsync 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.
///
///
///
/// Guarded-call detection uses Roslyn's semantic model: the analyzer compares the invoked
/// method's containing-type symbol (and every implemented interface symbol) against the
/// GuardedInterfaceTypes set resolved once per compilation, then verifies the
/// method either is the interface member or is the concrete implementation discovered via
/// . Matching by symbol
/// identity means a driver with an unusually-named method implementing
/// IReadable.ReadAsync still trips the rule.
///
///
/// Default-interface-method handling: IHistoryProvider.ReadAtTimeAsync and
/// IHistoryProvider.ReadEventsAsync ship as DIM bodies. When a driver inherits the
/// DIM (no override), FindImplementationForInterfaceMember returns the interface's
/// own method symbol, which still equals for an interface-typed
/// receiver. When a driver overrides the DIM, the override symbol equals
/// directly. Both paths are covered.
///
///
/// Wrapper-lambda detection: the analyzer walks up the syntax tree from the call
/// site and looks for an enclosing InvocationExpressionSyntax bound to
/// CapabilityInvoker.ExecuteAsync / CapabilityInvoker.ExecuteWriteAsync
/// whose argument list contains a lambda whose span contains the call. The match keys on
/// both the containing-type symbol AND the method name — a future overload on
/// CapabilityInvoker that took a non-callSite lambda (predicate, selector,
/// etc.) would still suppress the diagnostic; matching the method name is the strongest
/// containment check the analyzer can make without doing parameter-position binding on
/// every invocation in the IDE hot path. The known limitation is that any lambda
/// argument to ExecuteAsync / ExecuteWriteAsync suppresses the diagnostic
/// even if it is not bound to the callSite parameter — today both overload pairs
/// have exactly one lambda parameter (callSite), so the limitation is theoretical.
///
///
/// Why AlarmSurfaceInvoker is NOT a wrapper home: its public methods take
/// IReadOnlyList<...> / IAlarmSubscriptionHandle — no lambda
/// arguments — so no call to it can ever satisfy the lambda-containment check. Calls
/// inside AlarmSurfaceInvoker's own implementation are covered transitively
/// because the surface routes through the inner CapabilityInvoker.ExecuteAsync
/// lambda.
///
///
/// The rule does NOT enforce that the capability argument matches the method (e.g.
/// ReadAsync wrapped in ExecuteAsync(DriverCapability.Write, ...) still
/// passes) — that would require flow analysis beyond single-expression scope.
///
///
[DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "OTOPCUA0001";
/// Interfaces whose methods must be called through the capability invoker.
private static readonly string[] GuardedInterfaceMetadataNames =
[
"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",
];
///
/// Wrapper-method (containing-type metadata name, method name) pairs. Only a lambda bound
/// to one of these specific methods is treated as a valid wrapper home; other lambdas on
/// the same type (e.g. future predicate/selector overloads) do not suppress the diagnostic
/// unless they take a lambda in an argument position. AlarmSurfaceInvoker is not in
/// this list because none of its public methods accept lambda arguments — calls inside its
/// own implementation are covered transitively by the CapabilityInvoker.ExecuteAsync
/// match.
///
private static readonly (string TypeMetadataName, string MethodName)[] WrapperMethodKeys =
[
("ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker", "ExecuteAsync"),
("ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker", "ExecuteWriteAsync"),
];
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. 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 SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
///
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(OnCompilationStart);
}
private static void OnCompilationStart(CompilationStartAnalysisContext context)
{
// Resolve the guarded interfaces and wrapper types ONCE per compilation. If none of the
// guarded interfaces are referenced (e.g. an unrelated project building against this
// analyzer package), there's nothing to flag — register no further callbacks so the
// analyzer is a true no-op on cold compilations.
var guardedSet = new HashSet(SymbolEqualityComparer.Default);
foreach (var metadataName in GuardedInterfaceMetadataNames)
{
var sym = context.Compilation.GetTypeByMetadataName(metadataName);
if (sym is not null) guardedSet.Add(sym);
}
if (guardedSet.Count == 0) return;
// Wrapper methods: build a lookup keyed by (containing-type-symbol, method-name). Matching
// both means a future predicate/selector overload on CapabilityInvoker doesn't silently
// widen the suppression scope.
var wrapperMethodsByType = new Dictionary>(SymbolEqualityComparer.Default);
foreach (var (typeMetadataName, methodName) in WrapperMethodKeys)
{
var typeSym = context.Compilation.GetTypeByMetadataName(typeMetadataName);
if (typeSym is null) continue;
if (!wrapperMethodsByType.TryGetValue(typeSym, out var names))
{
names = new HashSet();
wrapperMethodsByType[typeSym] = names;
}
names.Add(methodName);
}
var state = new AnalyzerState(guardedSet, wrapperMethodsByType);
context.RegisterOperationAction(ctx => AnalyzeInvocation(ctx, state), OperationKind.Invocation);
}
private static void AnalyzeInvocation(OperationAnalysisContext context, AnalyzerState state)
{
var invocation = (IInvocationOperation)context.Operation;
// Defensive: a null SemanticModel means the IDE asked for analysis on a half-bound tree.
// We can't tell whether the call is inside a wrapper lambda without it, so SKIP rather
// than report — emitting a diagnostic in that state would be a false positive on code
// that is in fact correctly wrapped.
if (context.Operation.SemanticModel is null) return;
var method = invocation.TargetMethod;
if (method?.ContainingType is null) return;
if (method.ReturnType is null) return;
// 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, state.GuardedInterfaces)) return;
if (IsInsideWrapperLambda(invocation.Syntax, context.Operation.SemanticModel, state.WrapperMethodsByType, 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"
or "global::System.Threading.Tasks.ValueTask"
or "global::System.Threading.Tasks.ValueTask";
}
private static bool ImplementsGuardedInterface(IMethodSymbol method, HashSet guarded)
{
// The method may be defined directly on a guarded interface (interface-typed receiver) or
// be a concrete implementation. Walk every interface the containing type implements (plus
// the containing type itself, to catch the interface-typed-receiver case) and look for a
// member whose implementation in the containing type equals `method`, OR a member whose
// original definition equals `method.OriginalDefinition` (covers the DIM inherit case
// where FindImplementationForInterfaceMember returns the interface method itself).
var containingType = method.ContainingType;
foreach (var iface in containingType.AllInterfaces)
{
if (!guarded.Contains(iface.OriginalDefinition)) continue;
if (MethodImplementsInterfaceMember(method, containingType, iface)) return true;
}
// Interface-typed receiver: containingType *is* the guarded interface.
if (guarded.Contains(containingType.OriginalDefinition))
{
if (MethodImplementsInterfaceMember(method, containingType, containingType)) return true;
}
return false;
}
private static bool MethodImplementsInterfaceMember(IMethodSymbol method, INamedTypeSymbol containingType, INamedTypeSymbol iface)
{
foreach (var member in iface.GetMembers())
{
if (member is not IMethodSymbol memberMethod) continue;
var impl = containingType.FindImplementationForInterfaceMember(memberMethod);
if (SymbolEqualityComparer.Default.Equals(impl, method)) return true;
if (SymbolEqualityComparer.Default.Equals(method.OriginalDefinition, memberMethod)) return true;
}
return false;
}
private static bool IsInsideWrapperLambda(
SyntaxNode startNode,
SemanticModel semanticModel,
Dictionary> wrapperMethodsByType,
System.Threading.CancellationToken ct)
{
for (var node = startNode.Parent; node is not null; node = node.Parent)
{
// The call must literally live inside a lambda (ParenthesizedLambda / SimpleLambda /
// AnonymousMethod) that is an argument of one of the wrapper methods. Match both the
// containing-type symbol AND the method name — a future overload that took a
// predicate/selector lambda on the same type would still suppress the diagnostic IF it
// had the same method name, but a brand-new method (e.g. a hypothetical
// CapabilityInvoker.WithFilterAsync) would not. Today both wrapper method pairs have
// exactly one lambda parameter (the callSite), so the limitation is theoretical.
if (node is not InvocationExpressionSyntax outer) continue;
var sym = semanticModel.GetSymbolInfo(outer, ct).Symbol as IMethodSymbol;
if (sym?.ContainingType is null) continue;
var containingDef = sym.ContainingType.OriginalDefinition;
if (!wrapperMethodsByType.TryGetValue(containingDef, out var methodNames)) continue;
if (!methodNames.Contains(sym.Name)) continue;
// The call is wrapped IFF 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;
}
/// Holds analyzer state for a compilation, including resolved guarded interfaces and wrapper methods.
private sealed class AnalyzerState
{
/// Gets the set of guarded interface types resolved from the compilation.
public HashSet GuardedInterfaces { get; }
/// Gets the mapping of wrapper-method types to method names.
public Dictionary> WrapperMethodsByType { get; }
/// Initializes a new analyzer state with guarded interfaces and wrapper methods.
/// The set of guarded interface symbols.
/// The wrapper method lookup dictionary.
public AnalyzerState(
HashSet guardedInterfaces,
Dictionary> wrapperMethodsByType)
{
GuardedInterfaces = guardedInterfaces;
WrapperMethodsByType = wrapperMethodsByType;
}
}
}