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, /// CapabilityInvoker.ExecuteWriteAsync, or AlarmSurfaceInvoker.*Async 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. /// /// /// 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 IReadable.ReadAsync /// still trips the rule. Lambda-context detection walks up the syntax tree from the call site /// + checks whether any enclosing InvocationExpressionSyntax targets a member whose /// containing type is CapabilityInvoker or AlarmSurfaceInvoker. The rule is /// intentionally narrow: it does NOT try to enforce the capability argument matches the /// method (e.g. ReadAsync wrapped in ExecuteAsync(DriverCapability.Write, ...) still /// passes) — that'd 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[] 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", ]; /// Wrapper types whose lambda arguments are the allowed home for guarded calls. 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 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" or "global::System.Threading.Tasks.ValueTask" or "global::System.Threading.Tasks.ValueTask"; } 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()) { 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; } }