- Analyzers-002: drop the three dead AlarmSurfaceInvoker entries from the wrapper-method allow-list and from the diagnostic message. - Analyzers-003: bail out of AnalyzeInvocation when the semantic model is null (was previously emitting a false positive). - Analyzers-004: resolve guarded-interface + wrapper-method symbols once via CompilationStartAction and compare with SymbolEqualityComparer instead of formatting fully-qualified names on every invocation. - Analyzers-005: add regression tests for default-interface-method reads (ReadAtTimeAsync / ReadEventsAsync on a concrete driver), with + without an override, and inside a CapabilityInvoker.ExecuteAsync lambda. - Analyzers-007: rewrite the analyzer remarks to accurately describe the symbol-identity guarded-call detection, DIM handling, and the wrapper-lambda match heuristic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
858 lines
33 KiB
C#
858 lines
33 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 }
|
|
}
|
|
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!;
|
|
}
|
|
}
|
|
""";
|
|
|
|
[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");
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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
|
|
// -----------------------------------------------------------------------
|
|
|
|
[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();
|
|
}
|
|
|
|
// =======================================================================
|
|
// 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.
|
|
// =======================================================================
|
|
|
|
[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.
|
|
// =======================================================================
|
|
|
|
[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.
|
|
// =======================================================================
|
|
|
|
[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
|
|
// =======================================================================
|
|
|
|
[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");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|