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:
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user