fix(analyzers): resolve Low code-review findings (Analyzers-002,003,004,005,007)
- Analyzers-002: drop the three dead AlarmSurfaceInvoker entries from the wrapper-method allow-list and from the diagnostic message. - Analyzers-003: bail out of AnalyzeInvocation when the semantic model is null (was previously emitting a false positive). - Analyzers-004: resolve guarded-interface + wrapper-method symbols once via CompilationStartAction and compare with SymbolEqualityComparer instead of formatting fully-qualified names on every invocation. - Analyzers-005: add regression tests for default-interface-method reads (ReadAtTimeAsync / ReadEventsAsync on a concrete driver), with + without an override, and inside a CapabilityInvoker.ExecuteAsync lambda. - Analyzers-007: rewrite the analyzer remarks to accurately describe the symbol-identity guarded-call detection, DIM handling, and the wrapper-lambda match heuristic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,23 +10,56 @@ 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.
|
||||
/// methods when the call is NOT already running inside a <c>CapabilityInvoker.ExecuteAsync</c>
|
||||
/// or <c>CapabilityInvoker.ExecuteWriteAsync</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
|
||||
/// and checks whether any enclosing <c>InvocationExpressionSyntax</c> targets one of the
|
||||
/// specific wrapper methods listed in <c>WrapperMethods</c> (type + method name pair).
|
||||
/// Matching by method name as well as containing type ensures that a future overload on
|
||||
/// <c>CapabilityInvoker</c> that takes a predicate/selector lambda does not silently widen the
|
||||
/// suppression scope. The rule is intentionally narrow: it does NOT try to enforce that 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.
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>GuardedInterfaceTypes</c> set resolved once per compilation, then verifies the
|
||||
/// method either is the interface member or is the concrete implementation discovered via
|
||||
/// <see cref="ITypeSymbol.FindImplementationForInterfaceMember"/>. Matching by symbol
|
||||
/// identity means a driver with an unusually-named method implementing
|
||||
/// <c>IReadable.ReadAsync</c> still trips the rule.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Default-interface-method handling:</b> <c>IHistoryProvider.ReadAtTimeAsync</c> and
|
||||
/// <c>IHistoryProvider.ReadEventsAsync</c> ship as DIM bodies. When a driver inherits the
|
||||
/// DIM (no override), <c>FindImplementationForInterfaceMember</c> returns the interface's
|
||||
/// own method symbol, which still equals <paramref name="method"/> for an interface-typed
|
||||
/// receiver. When a driver overrides the DIM, the override symbol equals
|
||||
/// <paramref name="method"/> directly. Both paths are covered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Wrapper-lambda detection:</b> the analyzer walks up the syntax tree from the call
|
||||
/// site and looks for an enclosing <c>InvocationExpressionSyntax</c> bound to
|
||||
/// <c>CapabilityInvoker.ExecuteAsync</c> / <c>CapabilityInvoker.ExecuteWriteAsync</c>
|
||||
/// 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
|
||||
/// <c>CapabilityInvoker</c> that took a non-<c>callSite</c> 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 <c>ExecuteAsync</c> / <c>ExecuteWriteAsync</c> suppresses the diagnostic
|
||||
/// even if it is not bound to the <c>callSite</c> parameter — today both overload pairs
|
||||
/// have exactly one lambda parameter (<c>callSite</c>), so the limitation is theoretical.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why <c>AlarmSurfaceInvoker</c> is NOT a wrapper home:</b> its public methods take
|
||||
/// <c>IReadOnlyList<...></c> / <c>IAlarmSubscriptionHandle</c> — no lambda
|
||||
/// arguments — so no call to it can ever satisfy the lambda-containment check. Calls
|
||||
/// inside <c>AlarmSurfaceInvoker</c>'s own implementation are covered transitively
|
||||
/// because the surface routes through the inner <c>CapabilityInvoker.ExecuteAsync</c>
|
||||
/// lambda.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rule does NOT enforce that the capability argument matches the method (e.g.
|
||||
/// <c>ReadAsync</c> wrapped in <c>ExecuteAsync(DriverCapability.Write, ...)</c> still
|
||||
/// passes) — that would require flow analysis beyond single-expression scope.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
||||
public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||
@@ -34,7 +67,7 @@ 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 =
|
||||
private static readonly string[] GuardedInterfaceMetadataNames =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IWritable",
|
||||
@@ -46,24 +79,24 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper types paired with the method names that take a resilience <c>callSite</c> lambda.
|
||||
/// 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.
|
||||
/// 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. <c>AlarmSurfaceInvoker</c> is not in
|
||||
/// this list because none of its public methods accept lambda arguments — calls inside its
|
||||
/// own implementation are covered transitively by the <c>CapabilityInvoker.ExecuteAsync</c>
|
||||
/// match.
|
||||
/// </summary>
|
||||
private static readonly (string TypeFqn, string MethodName)[] WrapperMethods =
|
||||
private static readonly (string TypeMetadataName, string MethodName)[] WrapperMethodKeys =
|
||||
[
|
||||
("ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker", "ExecuteAsync"),
|
||||
("ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker", "ExecuteWriteAsync"),
|
||||
("ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker", "SubscribeAsync"),
|
||||
("ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker", "UnsubscribeAsync"),
|
||||
("ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker", "AcknowledgeAsync"),
|
||||
];
|
||||
|
||||
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.",
|
||||
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,
|
||||
@@ -75,20 +108,63 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
||||
context.RegisterCompilationStartAction(OnCompilationStart);
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
||||
private static void OnCompilationStart(CompilationStartAnalysisContext context)
|
||||
{
|
||||
var invocation = (Microsoft.CodeAnalysis.Operations.IInvocationOperation)context.Operation;
|
||||
// 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<INamedTypeSymbol>(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<INamedTypeSymbol, HashSet<string>>(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<string>();
|
||||
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)) return;
|
||||
if (IsInsideWrapperLambda(invocation.Syntax, context.Operation.SemanticModel, context.CancellationToken)) 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);
|
||||
@@ -103,59 +179,67 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||
or "global::System.Threading.Tasks.ValueTask<TResult>";
|
||||
}
|
||||
|
||||
private static bool ImplementsGuardedInterface(IMethodSymbol method)
|
||||
private static bool ImplementsGuardedInterface(IMethodSymbol method, HashSet<INamedTypeSymbol> guarded)
|
||||
{
|
||||
foreach (var iface in method.ContainingType.AllInterfaces.Concat(new[] { method.ContainingType }))
|
||||
// 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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 IsInsideWrapperLambda(SyntaxNode startNode, SemanticModel? semanticModel, System.Threading.CancellationToken ct)
|
||||
private static bool MethodImplementsInterfaceMember(IMethodSymbol method, INamedTypeSymbol containingType, INamedTypeSymbol iface)
|
||||
{
|
||||
if (semanticModel is null) return false;
|
||||
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<INamedTypeSymbol, HashSet<string>> wrapperMethodsByType,
|
||||
System.Threading.CancellationToken ct)
|
||||
{
|
||||
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 specific CapabilityInvoker.Execute* / AlarmSurfaceInvoker.*Async
|
||||
// method. Matching both the containing type AND the method name closes the gap where a
|
||||
// future overload on the same type that takes a predicate/selector lambda would
|
||||
// otherwise incorrectly suppress the diagnostic.
|
||||
// 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 is null) continue;
|
||||
if (sym?.ContainingType is null) continue;
|
||||
|
||||
var outerTypeFqn = sym.ContainingType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||
.Replace("global::", string.Empty);
|
||||
var methodName = sym.Name;
|
||||
var isWrapperMethod = false;
|
||||
foreach (var (typeFqn, name) in WrapperMethods)
|
||||
{
|
||||
if (typeFqn == outerTypeFqn && name == methodName)
|
||||
{
|
||||
isWrapperMethod = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isWrapperMethod) 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 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.
|
||||
// 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;
|
||||
@@ -164,4 +248,18 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class AnalyzerState
|
||||
{
|
||||
public HashSet<INamedTypeSymbol> GuardedInterfaces { get; }
|
||||
public Dictionary<INamedTypeSymbol, HashSet<string>> WrapperMethodsByType { get; }
|
||||
|
||||
public AnalyzerState(
|
||||
HashSet<INamedTypeSymbol> guardedInterfaces,
|
||||
Dictionary<INamedTypeSymbol, HashSet<string>> wrapperMethodsByType)
|
||||
{
|
||||
GuardedInterfaces = guardedInterfaces;
|
||||
WrapperMethodsByType = wrapperMethodsByType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user