fix(analyzers): resolve Low code-review findings (Analyzers-002,003,004,005,007)
- 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>
This commit is contained in:
@@ -51,11 +51,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions {
|
||||
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 }
|
||||
}
|
||||
@@ -586,6 +589,246 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
|
||||
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[]
|
||||
|
||||
Reference in New Issue
Block a user