Files
lmxopcua/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs
T
Joseph Doherty bacea1a44f fix(archreview #10): wire CapabilityInvoker into prod dispatch via a Polly-free seam
Remediates RESILIENCE-DISPATCH-GAP (surfaced by the 07/C-1 analyzer): the Phase 6.1
CapabilityInvoker resilience pipeline (retry / breaker / bulkhead / telemetry) was
constructed only in tests — the production dispatch layer called driver-capability
methods directly, bypassing it entirely.

The filed plan (thread CapabilityInvoker directly into DriverInstanceActor) is
INFEASIBLE: Runtime is deliberately Polly-free (references Core.Abstractions, not
Core — the same boundary IDriverFactory documents). So this introduces a seam,
mirroring IDriverFactory exactly:

- Core.Abstractions: IDriverCapabilityInvoker (+ NullDriverCapabilityInvoker
  pass-through) and IDriverCapabilityInvokerFactory (+ null factory).
- Core: CapabilityInvoker now implements the interface; new
  DriverCapabilityInvokerFactory builds a per-instance invoker over the
  process-singleton pipeline builder + status tracker + tier resolver.
- Runtime: DriverInstanceActor takes an IDriverCapabilityInvoker (default =
  pass-through) and routes all 6 dispatch sites (write, alarm-ack, subscribe,
  unsubscribe, alarm-subscribe, discover) through it; per-host key resolved via
  IPerCallHostResolver for single-ref calls, driver-instance key for bulk calls.
  DriverHostActor builds + injects the real invoker per spawned driver.
- Host: DriverFactoryBootstrap registers the tracker, pipeline builder, and
  concrete factory (needs DriverFactoryRegistry.GetTier). Runtime SCE resolves the
  factory from DI like IDriverFactory; pass-through on nodes without it bound.
- Analyzer: IDriverCapabilityInvoker.ExecuteAsync/ExecuteWriteAsync are now
  recognized wrapper homes (interface-typed invoker calls must suppress OTOPCUA0001
  too). All 6 RESILIENCE-DISPATCH-GAP pragmas removed — the analyzer (an error in
  Runtime) is now the standing regression guard.

Also adds retry / breaker-open / breaker-close LOGGING to the pipeline builder —
the operator-facing observability surface (the Admin /hosts reader, Phase 6.1
Stream E.2/E.3, was never built, so the pipeline otherwise runs silently).

Scope notes:
- Tier-DEFAULT policy only: DriverInstanceSpec (the deploy artifact) does not carry
  the per-instance ResilienceConfig JSON, so overrides aren't applied yet (tracked
  follow-up: plumb ResilienceConfig through the composer/artifact).
- GenericDriverNodeManager's call is NOT wired: that class is test-only scaffolding
  (zero production references — the Server has its own address-space path); its
  pragma is re-annotated accordingly, not left as a "tracked gap".

Verification (unit + analyzer; live behavioral gate still pending — see FOLLOWUP-10):
- Negative control: unwrapping any site fails the Runtime build with OTOPCUA0001.
- New Runtime guard (recording invoker): write routes via ExecuteWriteAsync with the
  IPerCallHostResolver host; subscribe via ExecuteAsync — proves runtime routing.
- New analyzer test: the interface is a valid wrapper home.
- New pipeline-builder test: retry events are logged.
- Full solution builds clean (0 errors); Runtime.Tests 357, Core.Tests 238,
  Analyzers.Tests 32 all green; pass-through default keeps existing dispatch tests
  byte-for-byte unchanged.
2026-07-08 21:33:16 -04:00

1042 lines
42 KiB
C#

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Analyzers;
namespace ZB.MOM.WW.OtOpcUa.Analyzers.Tests;
/// <summary>
/// Compile-a-snippet-and-run-the-analyzer tests. Avoids
/// Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit because it pins to xunit v2 +
/// this project uses xunit.v3 like the rest of the solution. Hand-rolled harness is 15
/// lines + makes the assertion surface obvious at the test-author level.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnwrappedCapabilityCallAnalyzerTests
{
/// <summary>Minimal stubs for the guarded interfaces + the two wrapper types. Keeps the
/// analyzer tests independent of the real OtOpcUa project references so a drift in those
/// 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;
public interface IReadable {
ValueTask<IReadOnlyList<object>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct);
}
public interface IWritable {
ValueTask WriteAsync(IReadOnlyList<object> ops, CancellationToken ct);
}
public interface ITagDiscovery {
Task DiscoverAsync(CancellationToken ct);
}
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 class HistoricalEventsResult { }
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();
Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime start, DateTime end, int maxEvents, CancellationToken ct)
=> throw new NotSupportedException();
}
public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge }
public interface IDriverCapabilityInvoker {
ValueTask<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct);
ValueTask ExecuteAsync(DriverCapability c, string host, Func<CancellationToken, ValueTask> call, CancellationToken ct);
ValueTask<T> ExecuteWriteAsync<T>(string host, bool isIdempotent, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct);
}
}
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;
public sealed class CapabilityInvoker {
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!;
}
}
""";
/// <summary>Verifies that a direct ReadAsync call in the Server namespace trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAsync_Call_InServerNamespace_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 BadCaller {
public async Task DoIt(IReadable driver) {
var _ = await driver.ReadAsync(new List<string>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].Id.ShouldBe(UnwrappedCapabilityCallAnalyzer.DiagnosticId);
diags[0].GetMessage().ShouldContain("ReadAsync");
}
/// <summary>Verifies that a wrapped ReadAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadAsync_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 GoodCaller {
public async Task DoIt(IReadable driver, CapabilityInvoker invoker) {
var _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await driver.ReadAsync(new List<string>(), ct), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
/// <summary>The Polly-free Runtime dispatch layer wraps calls through the
/// <c>IDriverCapabilityInvoker</c> seam (it cannot reference the concrete <c>CapabilityInvoker</c>).
/// An interface-typed invoker call must be recognised as a wrapper home too — arch-review #10.</summary>
[Fact]
public async Task Wrapped_InsideDriverCapabilityInvokerInterfaceLambda_PassesCleanly()
{
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 GoodSeamCaller {
public async Task Sub(ISubscribable driver, IDriverCapabilityInvoker invoker) {
var _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await driver.SubscribeAsync(new List<string>(), System.TimeSpan.Zero, ct), CancellationToken.None);
}
public async Task Wr(IReadable driver, IDriverCapabilityInvoker invoker) {
// ExecuteWriteAsync interface overload is a wrapper home too (value-returning call wrapped).
var _ = await invoker.ExecuteWriteAsync("h1", true,
async ct => await driver.ReadAsync(new List<string>(), ct), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
/// <summary>Verifies that a direct write without wrapper trips the diagnostic.</summary>
[Fact]
public async Task DirectWrite_WithoutWrapper_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 BadWrite {
public async Task DoIt(IWritable driver) {
await driver.WriteAsync(new List<object>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("WriteAsync");
}
/// <summary>Verifies that a discovery call without wrapper trips the diagnostic.</summary>
[Fact]
public async Task Discovery_Call_WithoutWrapper_TripsDiagnostic()
{
const string userSrc = """
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadDiscover {
public async Task DoIt(ITagDiscovery driver) {
await driver.DiscoverAsync(CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("DiscoverAsync");
}
/// <summary>Verifies that a call outside of a lambda but inside an invoker call still trips the diagnostic.</summary>
[Fact]
public async Task Call_OutsideOfLambda_ButInsideInvokerCall_StillTripsDiagnostic()
{
// Precompute the read *outside* the lambda, then pass the awaited result — that does NOT
// actually wrap the ReadAsync call in the resilience pipeline, so the analyzer must
// still flag it (regression guard: a naive "any mention of ExecuteAsync nearby" rule
// would silently let this pattern through).
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 SneakyCaller {
public async Task DoIt(IReadable driver, CapabilityInvoker invoker) {
var result = await driver.ReadAsync(new List<string>(), CancellationToken.None); // not inside any lambda
await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => { await Task.Yield(); }, CancellationToken.None);
_ = result;
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
}
// -----------------------------------------------------------------------
// ISubscribable
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct SubscribeAsync call trips the diagnostic.</summary>
[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");
}
/// <summary>Verifies that a wrapped SubscribeAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct SubscribeAlarmsAsync call trips the diagnostic.</summary>
[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");
}
/// <summary>Verifies that a wrapped SubscribeAlarmsAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct ReadRawAsync call on an interface trips the diagnostic.</summary>
[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");
}
/// <summary>Verifies that a direct ReadProcessedAsync call on an interface trips the diagnostic.</summary>
[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");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on an interface trips the diagnostic.</summary>
[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");
}
/// <summary>Verifies that a wrapped ReadRawAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[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)
// -----------------------------------------------------------------------
/// <summary>Verifies that a direct HistoryRead call on a concrete driver trips the diagnostic.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a synchronous GetHostStatuses call is not flagged.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a concrete driver with a renamed read method still trips the diagnostic.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a wrapped WriteAsync call inside ExecuteWriteAsync passes cleanly.</summary>
[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
// -----------------------------------------------------------------------
/// <summary>Verifies that a guarded call inside a nested non-wrapper lambda trips the diagnostic.</summary>
[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");
}
/// <summary>Verifies that a guarded call inside a wrapper lambda inside a non-wrapper lambda passes cleanly.</summary>
[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();
}
// =======================================================================
// Analyzers-002 — AlarmSurfaceInvoker has no lambda parameters; calls inside
// its own method bodies are covered transitively by the CapabilityInvoker
// match because the actual wrapping lambda lives on _invoker.ExecuteAsync.
// =======================================================================
/// <summary>Verifies that a guarded call inside an alarm surface invoker method wrapped by CapabilityInvoker passes cleanly.</summary>
[Fact]
public async Task GuardedCall_InsideAlarmSurfaceInvokerMethod_WrappedByCapabilityInvoker_PassesCleanly()
{
// Mirrors the real AlarmSurfaceInvoker pattern: it owns an inner CapabilityInvoker
// and routes IAlarmSource calls through CapabilityInvoker.ExecuteAsync. The transitive
// CapabilityInvoker wrapper match is what suppresses the diagnostic — the analyzer
// does NOT need an AlarmSurfaceInvoker-typed lambda escape hatch (it has no lambda
// params anyway).
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.Core.Resilience.Test {
public sealed class FakeAlarmSurface {
private readonly CapabilityInvoker _invoker;
private readonly IAlarmSource _source;
public FakeAlarmSurface(CapabilityInvoker i, IAlarmSource s) { _invoker = i; _source = s; }
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(IReadOnlyList<string> ids, CancellationToken ct) {
return await _invoker.ExecuteAsync(DriverCapability.AlarmSubscribe, "h1",
async cct => await _source.SubscribeAlarmsAsync(ids, cct), ct);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-003 — false positives must not appear on valid code. We can't
// easily force a null SemanticModel through RegisterOperationAction, but we
// can pin the no-false-positive contract for unrelated invocations.
// =======================================================================
/// <summary>Verifies that a non-guarded async call does not trip the diagnostic.</summary>
[Fact]
public async Task NonGuardedAsyncCall_DoesNotTrip()
{
// Unrelated async call on a non-guarded type — must not be flagged regardless of
// analyzer internals (semantic-model availability, etc.).
const string userSrc = """
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Server {
public interface IUnrelated { Task DoStuffAsync(CancellationToken ct); }
public sealed class UnrelatedCaller {
public async Task DoIt(IUnrelated x) {
await x.DoStuffAsync(CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-004 — when the Core.Abstractions guarded interfaces are not
// referenced at all, the analyzer must be a no-op for the compilation.
// =======================================================================
/// <summary>Verifies that a compilation without guarded interface references emits no diagnostics.</summary>
[Fact]
public async Task Compilation_WithoutGuardedInterfaceReferences_EmitsNoDiagnostics()
{
// A standalone compilation that does not pull in the StubSources at all — the
// RegisterCompilationStartAction symbol-resolution fast-path must skip cleanly
// when none of the guarded types exist.
const string userSrc = """
using System.Threading;
using System.Threading.Tasks;
namespace SomeOther {
public interface IReadable { Task ReadAsync(CancellationToken ct); }
public sealed class Caller {
public async Task DoIt(IReadable x) {
await x.ReadAsync(CancellationToken.None);
}
}
}
""";
var diags = await CompileWithoutStubs(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-005 — IHistoryProvider default-interface-method asymmetry
// =======================================================================
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// ConcreteHistoryDriver does NOT override ReadAtTimeAsync (it inherits the DIM).
// The call site has a concrete-receiver type — the analyzer must still flag the call
// because the bound method (ReadAtTimeAsync) is the interface's own default impl.
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 PartialHistoryDriver : 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!;
// ReadAtTimeAsync + ReadEventsAsync NOT overridden — driver inherits the DIM throws.
}
public sealed class BadPartialHistoryCaller {
public async Task DoIt(PartialHistoryDriver driver) {
// Cast through the interface so the binder resolves to IHistoryProvider.ReadAtTimeAsync
// (the DIM). FindImplementationForInterfaceMember returns the interface method itself
// when nothing overrides it.
var iface = (IHistoryProvider)driver;
_ = await iface.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a direct ReadEventsAsync call on a concrete driver inheriting the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadEventsAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic()
{
// Same as above but for ReadEventsAsync — confirms both DIMs are caught when the
// driver doesn't override them.
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 PartialHistoryDriver2 : 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 BadPartialHistoryEventsCaller {
public async Task DoIt(PartialHistoryDriver2 driver) {
var iface = (IHistoryProvider)driver;
_ = await iface.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 100, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadEventsAsync");
}
/// <summary>Verifies that a direct ReadAtTimeAsync call on a concrete driver overriding the DIM trips the diagnostic.</summary>
[Fact]
public async Task Direct_ReadAtTimeAsync_OnConcreteDriverOverridingDIM_TripsDiagnostic()
{
// Driver explicitly overrides ReadAtTimeAsync — the concrete-receiver call goes through
// the override; FindImplementationForInterfaceMember returns the override method, which
// SymbolEqualityComparer matches against method.
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 FullHistoryDriver : 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 Task<HistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> ts, CancellationToken ct) => throw null!;
}
public sealed class BadFullHistoryCaller {
public async Task DoIt(FullHistoryDriver driver) {
_ = await driver.ReadAtTimeAsync("tag", new List<DateTime>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("ReadAtTimeAsync");
}
/// <summary>Verifies that a wrapped ReadAtTimeAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadAtTimeAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// DIM wrapped properly — must not trip.
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 GoodHistoryAtTime {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadAtTimeAsync("tag", new List<DateTime>(), ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
// =======================================================================
// Analyzers-008 — missing trip + pass-clean tests for ISubscribable.UnsubscribeAsync,
// IAlarmSource.UnsubscribeAlarmsAsync, IAlarmSource.AcknowledgeAsync, and the wrapped
// (pass-clean) path for IHistoryProvider.ReadEventsAsync (DIM).
// =======================================================================
/// <summary>Verifies that a direct UnsubscribeAsync call trips the diagnostic.</summary>
[Fact]
public async Task Direct_UnsubscribeAsync_Call_TripsDiagnostic()
{
const string userSrc = """
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadUnsubscribe {
public async Task DoIt(ISubscribable driver, ISubscriptionHandle handle) {
await driver.UnsubscribeAsync(handle, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("UnsubscribeAsync");
}
/// <summary>Verifies that a wrapped UnsubscribeAsync call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_UnsubscribeAsync_InsideCapabilityInvokerLambda_PassesCleanly()
{
const string userSrc = """
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 GoodUnsubscribe {
public async Task DoIt(ISubscribable driver, ISubscriptionHandle handle, CapabilityInvoker invoker) {
await invoker.ExecuteAsync(DriverCapability.AlarmSubscribe, "h1",
async ct => await driver.UnsubscribeAsync(handle, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
/// <summary>Verifies that a direct UnsubscribeAlarmsAsync call trips the diagnostic.</summary>
[Fact]
public async Task Direct_UnsubscribeAlarmsAsync_Call_TripsDiagnostic()
{
const string userSrc = """
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server {
public sealed class BadAlarmUnsubscribe {
public async Task DoIt(IAlarmSource source, IAlarmSubscriptionHandle handle) {
await source.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("UnsubscribeAlarmsAsync");
}
/// <summary>Verifies that a direct AcknowledgeAsync call trips the diagnostic.</summary>
[Fact]
public async Task Direct_AcknowledgeAsync_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 BadAcknowledge {
public async Task DoIt(IAlarmSource source) {
await source.AcknowledgeAsync(new List<string>(), CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.Length.ShouldBe(1);
diags[0].GetMessage().ShouldContain("AcknowledgeAsync");
}
/// <summary>Verifies that a wrapped ReadEventsAsync DIM call inside a CapabilityInvoker lambda passes cleanly.</summary>
[Fact]
public async Task Wrapped_ReadEventsAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly()
{
// ReadEventsAsync is a DIM — the wrapped path must not trip the diagnostic.
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 GoodHistoryEvents {
public async Task DoIt(IHistoryProvider provider, CapabilityInvoker invoker) {
_ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
async ct => await provider.ReadEventsAsync(null, DateTime.MinValue, DateTime.MaxValue, 0, ct),
CancellationToken.None);
}
}
}
""";
var diags = await Compile(userSrc);
diags.ShouldBeEmpty();
}
private static async Task<ImmutableArray<Diagnostic>> CompileWithoutStubs(string userSource)
{
var syntaxTrees = new[] { CSharpSyntaxTree.ParseText(userSource) };
var references = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location))
.Cast<MetadataReference>()
.ToList();
var compilation = CSharpCompilation.Create(
assemblyName: "AnalyzerTestAssembly_NoStubs",
syntaxTrees: syntaxTrees,
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var withAnalyzers = compilation.WithAnalyzers(
ImmutableArray.Create<DiagnosticAnalyzer>(new UnwrappedCapabilityCallAnalyzer()));
var allDiags = await withAnalyzers.GetAnalyzerDiagnosticsAsync();
return allDiags.Where(d => d.Id == UnwrappedCapabilityCallAnalyzer.DiagnosticId).ToImmutableArray();
}
private static async Task<ImmutableArray<Diagnostic>> Compile(string userSource)
{
var syntaxTrees = new[]
{
CSharpSyntaxTree.ParseText(StubSources),
CSharpSyntaxTree.ParseText(userSource),
};
var references = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location))
.Cast<MetadataReference>()
.ToList();
var compilation = CSharpCompilation.Create(
assemblyName: "AnalyzerTestAssembly",
syntaxTrees: syntaxTrees,
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var withAnalyzers = compilation.WithAnalyzers(
ImmutableArray.Create<DiagnosticAnalyzer>(new UnwrappedCapabilityCallAnalyzer()));
var allDiags = await withAnalyzers.GetAnalyzerDiagnosticsAsync();
return allDiags.Where(d => d.Id == UnwrappedCapabilityCallAnalyzer.DiagnosticId).ToImmutableArray();
}
}