fix(analyzers): resolve Medium code-review findings (Analyzers-001, Analyzers-006)

Analyzers-001: IsInsideWrapperLambda now matches the wrapper method name
(ExecuteAsync/ExecuteWriteAsync) in addition to the containing type, so a
future non-callSite lambda overload cannot suppress the diagnostic.
Analyzers-006: extended StubSources and added coverage for the remaining
guarded interfaces, synchronous members, concrete-driver receivers,
ExecuteWriteAsync wrapping, and nested lambdas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:08:09 -04:00
parent a0aa4a4819
commit 9f5a5c9997
3 changed files with 460 additions and 17 deletions

View File

@@ -19,11 +19,14 @@ namespace ZB.MOM.WW.OtOpcUa.Analyzers;
/// 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.
/// 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.
/// </remarks>
[DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
@@ -42,11 +45,19 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
"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 =
/// <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.
/// </summary>
private static readonly (string TypeFqn, string MethodName)[] WrapperMethods =
[
"ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker",
"ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker",
("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(
@@ -119,7 +130,10 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
{
// 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.
// 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.
if (node is not InvocationExpressionSyntax outer) continue;
var sym = semanticModel.GetSymbolInfo(outer, ct).Symbol as IMethodSymbol;
@@ -127,7 +141,17 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
var outerTypeFqn = sym.ContainingType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", string.Empty);
if (!WrapperTypes.Contains(outerTypeFqn)) continue;
var methodName = sym.Name;
var isWrapperMethod = false;
foreach (var (typeFqn, name) in WrapperMethods)
{
if (typeFqn == outerTypeFqn && name == methodName)
{
isWrapperMethod = true;
break;
}
}
if (!isWrapperMethod) 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