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