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

@@ -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<CancellationToken, ValueTask>` / `Func<CancellationToken, ValueTask<T>>`) 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<CancellationToken, ValueTask>` / `Func<CancellationToken, ValueTask<T>>` 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

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

View File

@@ -22,6 +22,7 @@ public sealed class UnwrappedCapabilityCallAnalyzerTests
/// signatures doesn't secretly mute the analyzer check.</summary>
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<ISubscriptionHandle> SubscribeAsync(IReadOnlyList<string> refs, TimeSpan interval, CancellationToken ct);
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken ct);
}
public interface IAlarmSubscriptionHandle { string DiagnosticId { get; } }
public interface IAlarmSource {
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> sourceNodeIds, CancellationToken ct);
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken ct);
Task AcknowledgeAsync(IReadOnlyList<string> acks, CancellationToken ct);
}
public class HostConnectivityStatus { public string HostName { get; init; } = ""; }
public interface IHostConnectivityProbe {
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
}
public class HistoryReadResult { }
public interface IHistoryProvider {
Task<HistoryReadResult> ReadRawAsync(string fullRef, DateTime start, DateTime end, uint max, CancellationToken ct);
Task<HistoryReadResult> ReadProcessedAsync(string fullRef, DateTime start, DateTime end, TimeSpan interval, CancellationToken ct);
Task<HistoryReadResult> ReadAtTimeAsync(string fullRef, IReadOnlyList<DateTime> 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<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct) => throw null!;
public ValueTask ExecuteAsync(DriverCapability c, string host, Func<CancellationToken, ValueTask> call, CancellationToken ct) => throw null!;
public ValueTask<T> ExecuteWriteAsync<T>(string host, bool isIdempotent, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct) => throw null!;
public ValueTask ExecuteWriteAsync(string host, bool isIdempotent, Func<CancellationToken, ValueTask> call, CancellationToken ct) => throw null!;
}
public sealed class AlarmSurfaceInvoker {
public Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(IReadOnlyList<string> sourceNodeIds, CancellationToken ct) => throw null!;
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken ct) => throw null!;
public Task AcknowledgeAsync(IReadOnlyList<string> 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<string>(), 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<string>(), 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<string>(), 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<string>(), 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<DateTime>(), 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<HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => throw null!;
public Task<HistoryReadResult> 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<IReadOnlyList<object>> IReadable.ReadAsync(IReadOnlyList<string> tags, CancellationToken ct)
=> InternalFetchAsync(tags, ct);
public ValueTask<IReadOnlyList<object>> InternalFetchAsync(IReadOnlyList<string> 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<string>(), 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<object>(), 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<Task> work = async () => {
_ = await driver.ReadAsync(new List<string>(), 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<Task> work = async () => {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await driver.ReadAsync(new List<string>(), ct),
CancellationToken.None);
};
await work();
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
private static async Task<ImmutableArray<Diagnostic>> Compile(string userSource)
{
var syntaxTrees = new[]