diff --git a/code-reviews/Analyzers/findings.md b/code-reviews/Analyzers/findings.md index 4442d60..59eaa85 100644 --- a/code-reviews/Analyzers/findings.md +++ b/code-reviews/Analyzers/findings.md @@ -7,7 +7,7 @@ | Review date | 2026-05-22 | | Commit reviewed | `76d35d1` | | Status | Reviewed | -| Open findings | 7 | +| Open findings | 5 | ## Checklist coverage @@ -33,13 +33,13 @@ | Severity | Medium | | Category | Correctness & logic bugs | | Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:135-139` | -| Status | Open | +| Status | Resolved | **Description:** `IsInsideWrapperLambda` treats a guarded call as "wrapped" if it is textually inside ANY lambda that is an argument to ANY invocation whose containing type is `CapabilityInvoker` or `AlarmSurfaceInvoker`. It matches the containing type only, never the parameter the lambda is bound to. The real wrapping contract is specifically the `callSite` (`Func` / `Func>`) parameter of `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync`. Any other lambda argument to a method on those types — a future overload that takes a predicate/selector lambda, or a lambda passed in a non-`callSite` position — would suppress the diagnostic even though the guarded call is not actually executed inside the resilience pipeline. The analyzer's own XML doc (lines 21-23) describes exactly this looser-than-intended behaviour. It is a latent false-negative gap rather than an active bug because the current `CapabilityInvoker` surface has no non-`callSite` lambda parameter. **Recommendation:** Resolve the symbol of the lambda argument's parameter (`IMethodSymbol.Parameters[i]`) and require its type to be the `Func` / `Func>` callsite shape, or at minimum match the wrapper method name (`ExecuteAsync` / `ExecuteWriteAsync`) rather than only the containing type. This closes the gap before a new overload silently widens the escape hatch. -**Resolution:** _(open)_ +**Resolution:** Resolved 2026-05-22 — Replaced `WrapperTypes` string array with `WrapperMethods` (type FQN + method name) tuples so `IsInsideWrapperLambda` matches both containing type and method name, preventing future non-`callSite` overloads from silently suppressing the diagnostic. ### Analyzers-002 @@ -108,7 +108,7 @@ | Severity | Medium | | Category | Testing coverage | | Location | `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs` | -| Status | Open | +| Status | Resolved | **Description:** The test suite exercises only 3 of the 7 guarded interfaces (`IReadable`, `IWritable`, `ITagDiscovery`) and one positive / one negative lambda case. Significant untested behaviour for an analyzer that gates a repo-wide resilience invariant: @@ -121,7 +121,7 @@ **Recommendation:** Extend `StubSources` with the remaining guarded interfaces and `AlarmSurfaceInvoker`, then add tests for: each remaining guarded interface (positive plus wrapped), a synchronous member not being flagged, a concrete driver-class receiver with a renamed implementing method, `ExecuteWriteAsync` wrapping, and a nested-lambda case. -**Resolution:** _(open)_ +**Resolution:** Resolved 2026-05-22 — Extended `StubSources` with `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`, and `AlarmSurfaceInvoker` stubs; added 14 new tests covering each missing guarded interface (positive + wrapped), synchronous member not flagged, concrete driver receiver, `ExecuteWriteAsync` wrapping, and nested-lambda cases (19 tests total, all passing). ### Analyzers-007 diff --git a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs index 940db71..ddffa50 100644 --- a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs +++ b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs @@ -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 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. +/// and checks whether any enclosing InvocationExpressionSyntax targets one of the +/// specific wrapper methods listed in WrapperMethods (type + method name pair). +/// Matching by method name as well as containing type ensures that a future overload on +/// CapabilityInvoker 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 +/// ExecuteAsync(DriverCapability.Write, ...) still passes) — that'd require flow +/// analysis beyond single-expression scope. /// [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", ]; - /// Wrapper types whose lambda arguments are the allowed home for guarded calls. - private static readonly string[] WrapperTypes = + /// + /// Wrapper types paired with the method names that take a resilience callSite 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. + /// + 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 diff --git a/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs b/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs index a43dbba..0e2efd2 100644 --- a/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs +++ b/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs @@ -22,6 +22,7 @@ public sealed class UnwrappedCapabilityCallAnalyzerTests /// signatures doesn't secretly mute the analyzer check. private const string StubSources = """ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions { + using System; using System.Threading; using System.Threading.Tasks; using System.Collections.Generic; @@ -34,10 +35,33 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions { public interface ITagDiscovery { Task DiscoverAsync(CancellationToken ct); } - public enum DriverCapability { Read, Write, Discover } + public interface ISubscriptionHandle { string DiagnosticId { get; } } + public interface ISubscribable { + Task SubscribeAsync(IReadOnlyList refs, TimeSpan interval, CancellationToken ct); + Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken ct); + } + public interface IAlarmSubscriptionHandle { string DiagnosticId { get; } } + public interface IAlarmSource { + Task SubscribeAlarmsAsync(IReadOnlyList sourceNodeIds, CancellationToken ct); + Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken ct); + Task AcknowledgeAsync(IReadOnlyList acks, CancellationToken ct); + } + public class HostConnectivityStatus { public string HostName { get; init; } = ""; } + public interface IHostConnectivityProbe { + IReadOnlyList GetHostStatuses(); + } + public class HistoryReadResult { } + public interface IHistoryProvider { + Task ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct); + Task ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct); + Task ReadAtTimeAsync(string fullRef, IReadOnlyList timestamps, CancellationToken ct) + => throw new NotSupportedException(); + } + public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge } } namespace ZB.MOM.WW.OtOpcUa.Core.Resilience { using System; + using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -45,6 +69,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience { public ValueTask ExecuteAsync(DriverCapability c, string host, Func> call, CancellationToken ct) => throw null!; public ValueTask ExecuteAsync(DriverCapability c, string host, Func call, CancellationToken ct) => throw null!; public ValueTask ExecuteWriteAsync(string host, bool isIdempotent, Func> call, CancellationToken ct) => throw null!; + public ValueTask ExecuteWriteAsync(string host, bool isIdempotent, Func call, CancellationToken ct) => throw null!; + } + public sealed class AlarmSurfaceInvoker { + public Task> SubscribeAsync(IReadOnlyList sourceNodeIds, CancellationToken ct) => throw null!; + public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken ct) => throw null!; + public Task AcknowledgeAsync(IReadOnlyList acks, CancellationToken ct) => throw null!; } } """; @@ -167,6 +197,395 @@ namespace ZB.MOM.WW.OtOpcUa.Server { diags.Length.ShouldBe(1); } + // ----------------------------------------------------------------------- + // ISubscribable + // ----------------------------------------------------------------------- + + [Fact] + public async Task Direct_SubscribeAsync_Call_TripsDiagnostic() + { + const string userSrc = """ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class BadSubscribe { + public async Task DoIt(ISubscribable driver) { + _ = await driver.SubscribeAsync(new List(), TimeSpan.FromSeconds(1), CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("SubscribeAsync"); + } + + [Fact] + public async Task Wrapped_SubscribeAsync_InsideCapabilityInvokerLambda_PassesCleanly() + { + const string userSrc = """ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Resilience; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class GoodSubscribe { + public async Task DoIt(ISubscribable driver, CapabilityInvoker invoker) { + _ = await invoker.ExecuteAsync(DriverCapability.AlarmSubscribe, "h1", + async ct => await driver.SubscribeAsync(new List(), TimeSpan.FromSeconds(1), ct), + CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + + // ----------------------------------------------------------------------- + // IAlarmSource + // ----------------------------------------------------------------------- + + [Fact] + public async Task Direct_SubscribeAlarmsAsync_Call_TripsDiagnostic() + { + const string userSrc = """ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class BadAlarmSubscribe { + public async Task DoIt(IAlarmSource source) { + _ = await source.SubscribeAlarmsAsync(new List(), CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("SubscribeAlarmsAsync"); + } + + [Fact] + public async Task Wrapped_SubscribeAlarmsAsync_InsideCapabilityInvokerLambda_PassesCleanly() + { + const string userSrc = """ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Resilience; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class GoodAlarmSubscribe { + public async Task DoIt(IAlarmSource source, CapabilityInvoker invoker) { + _ = await invoker.ExecuteAsync(DriverCapability.AlarmSubscribe, "h1", + async ct => await source.SubscribeAlarmsAsync(new List(), ct), + CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + + // ----------------------------------------------------------------------- + // IHistoryProvider — interface-typed receiver + // ----------------------------------------------------------------------- + + [Fact] + public async Task Direct_ReadRawAsync_OnInterface_TripsDiagnostic() + { + const string userSrc = """ +using System; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class BadHistoryRead { + public async Task DoIt(IHistoryProvider provider) { + _ = await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("ReadRawAsync"); + } + + [Fact] + public async Task Direct_ReadProcessedAsync_OnInterface_TripsDiagnostic() + { + const string userSrc = """ +using System; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class BadHistoryProcessed { + public async Task DoIt(IHistoryProvider provider) { + _ = await provider.ReadProcessedAsync("tag", DateTime.MinValue, DateTime.MaxValue, TimeSpan.FromMinutes(1), CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("ReadProcessedAsync"); + } + + [Fact] + public async Task Direct_ReadAtTimeAsync_OnInterface_TripsDiagnostic() + { + // ReadAtTimeAsync is a default interface method — the analyzer must still flag it when + // called through an interface-typed receiver. + const string userSrc = """ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class BadHistoryAtTime { + public async Task DoIt(IHistoryProvider provider) { + _ = await provider.ReadAtTimeAsync("tag", new List(), CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("ReadAtTimeAsync"); + } + + [Fact] + public async Task Wrapped_ReadRawAsync_InsideCapabilityInvokerLambda_PassesCleanly() + { + const string userSrc = """ +using System; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Resilience; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class GoodHistoryRead { + public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) { + _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1", + async ct => await provider.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, ct), + CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + + // ----------------------------------------------------------------------- + // IHistoryProvider — concrete driver receiver (FindImplementationForInterfaceMember branch) + // ----------------------------------------------------------------------- + + [Fact] + public async Task Direct_HistoryRead_OnConcreteDriver_TripsDiagnostic() + { + // ConcreteHistoryDriver implements IHistoryProvider with explicitly-named methods + // (same names here, but the test exercises the FindImplementationForInterfaceMember + // branch because the receiver type is the concrete class, not the interface). + const string userSrc = """ +using System; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class ConcreteHistoryDriver : IHistoryProvider { + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!; + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, CancellationToken ct) => throw null!; + } + public sealed class BadConcreteHistoryRead { + public async Task DoIt(ConcreteHistoryDriver driver) { + _ = await driver.ReadRawAsync("tag", DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("ReadRawAsync"); + } + + // ----------------------------------------------------------------------- + // IHostConnectivityProbe — synchronous member must NOT be flagged + // ----------------------------------------------------------------------- + + [Fact] + public async Task Synchronous_GetHostStatuses_IsNotFlagged() + { + // GetHostStatuses() is synchronous — the IsAsyncReturningType filter must exclude it. + const string userSrc = """ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class SafeConnectivityCaller { + public void DoIt(IHostConnectivityProbe probe) { + _ = probe.GetHostStatuses(); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + + // ----------------------------------------------------------------------- + // Concrete driver receiver with a renamed implementing method + // ----------------------------------------------------------------------- + + [Fact] + public async Task ConcreteDriver_RenamedReadMethod_StillTripsDiagnostic() + { + // A driver whose read implementation has a different name than ReadAsync but is the + // explicit interface implementation — the FindImplementationForInterfaceMember branch + // in ImplementsGuardedInterface must catch it. + const string userSrc = """ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Drivers.Fancy { + public sealed class FancyDriver : IReadable { + ValueTask> IReadable.ReadAsync(IReadOnlyList tags, CancellationToken ct) + => InternalFetchAsync(tags, ct); + public ValueTask> InternalFetchAsync(IReadOnlyList tags, CancellationToken ct) + => throw null!; + } +} +namespace ZB.MOM.WW.OtOpcUa.Server { + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + using ZB.MOM.WW.OtOpcUa.Drivers.Fancy; + public sealed class BadFancyCaller { + public async Task DoIt(FancyDriver driver) { + // Called through the interface — explicit implementation routes to IReadable.ReadAsync + var iface = (IReadable)driver; + var _ = await iface.ReadAsync(new List(), CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("ReadAsync"); + } + + // ----------------------------------------------------------------------- + // ExecuteWriteAsync wrapping + // ----------------------------------------------------------------------- + + [Fact] + public async Task Wrapped_WriteAsync_InsideExecuteWriteAsync_PassesCleanly() + { + const string userSrc = """ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Resilience; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class GoodWriteViaExecuteWrite { + public async Task DoIt(IWritable driver, CapabilityInvoker invoker) { + await invoker.ExecuteWriteAsync("h1", isIdempotent: false, + async ct => await driver.WriteAsync(new List(), ct), + CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + + // ----------------------------------------------------------------------- + // Nested lambda — inner guarded call must not be suppressed by outer non-wrapper lambda + // ----------------------------------------------------------------------- + + [Fact] + public async Task GuardedCall_InsideNestedNonWrapperLambda_TripsDiagnostic() + { + // The ReadAsync call is inside a Func<...> that is NOT an argument to a wrapper method. + // A naive ancestry walk that stops at any lambda would silently suppress this. + const string userSrc = """ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class NestedLambdaCaller { + public async Task DoIt(IReadable driver) { + Func work = async () => { + _ = await driver.ReadAsync(new List(), CancellationToken.None); + }; + await work(); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.Length.ShouldBe(1); + diags[0].GetMessage().ShouldContain("ReadAsync"); + } + + [Fact] + public async Task GuardedCall_InsideWrapperLambda_InsideNonWrapperLambda_PassesCleanly() + { + // The ReadAsync call IS inside a CapabilityInvoker.ExecuteAsync lambda, + // even though that lambda is also nested inside an outer non-wrapper lambda. + const string userSrc = """ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Resilience; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class DoubleNestedGoodCaller { + public async Task DoIt(IReadable driver, CapabilityInvoker invoker) { + Func work = async () => { + _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1", + async ct => await driver.ReadAsync(new List(), ct), + CancellationToken.None); + }; + await work(); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + private static async Task> Compile(string userSource) { var syntaxTrees = new[]