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:
Joseph Doherty
2026-05-23 05:38:37 -04:00
parent 0da4f3b63a
commit 0993fa5a19
3 changed files with 420 additions and 79 deletions

View File

@@ -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[]