review(Analyzers): add trip-coverage for async guarded-interface methods

Re-review at 7286d320. -008: 5 regression tests for Unsubscribe/UnsubscribeAlarms/
Acknowledge/ReadEvents trip + suppression paths (analyzer source already correct).
Surfaced cross-module: Runtime DriverInstanceActor.HandleWriteAsync calls WriteAsync
directly (tracked for Runtime).
This commit is contained in:
Joseph Doherty
2026-06-19 11:21:35 -04:00
parent 6b4210cb17
commit 13c1215811
2 changed files with 164 additions and 2 deletions
+41 -2
View File
@@ -4,8 +4,8 @@
|---|---|
| Module | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers` |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 0 |
@@ -137,3 +137,42 @@
**Recommendation:** Tighten the `<remarks>` to state precisely what `IsInsideWrapperLambda` checks today (textual containment within a lambda argument of a `CapabilityInvoker` / `AlarmSurfaceInvoker`-typed invocation), and note the known limitation that it does not bind the lambda to the `callSite` parameter. Keep the doc in sync if Analyzers-001 is fixed.
**Resolution:** Resolved 2026-05-23 — Rewrote the analyzer's `<remarks>` into five precise paragraphs: (1) guarded-call detection uses symbol identity, (2) DIM handling (covers Analyzers-005), (3) wrapper-lambda detection matches both containing-type symbol AND method name, with the lambda-not-bound-to-callSite-parameter limitation called out explicitly, (4) why `AlarmSurfaceInvoker` is not a wrapper home (covers Analyzers-002 narrative), (5) the existing capability-argument-not-enforced caveat. The doc is now in sync with the post-Analyzers-001/-002/-004 implementation.
## Re-review 2026-06-19 (commit 7286d320)
All seven prior findings (Analyzers-001 through Analyzers-007) were closed at the previous review commit and the fixes are present at HEAD. The 273-line source and 883-line test file were re-read in full. The diff from `76d35d1` to `7286d320` is entirely the collection of prior-round fixes plus a migration of the test project's Roslyn package references to central package management (removing explicit `5.3.0` pins that conflicted with the SDK-shipped `5.0.0` compiler on this machine — correct change).
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | No new issues |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | No issues found |
| 4 | Error handling & resilience | No issues found |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Analyzers-008 |
| 10 | Documentation & comments | No issues found |
### Analyzers-008
| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs` |
| Status | Resolved |
**Description:** The 26-test suite added in the previous round covers most guarded-interface methods but leaves three async methods on guarded interfaces with no test at all and one wrapped-path (pass-clean) test missing:
- `ISubscribable.UnsubscribeAsync` — no positive-trip test, no wrapped-pass test.
- `IAlarmSource.UnsubscribeAlarmsAsync` — no positive-trip test, no wrapped-pass test.
- `IAlarmSource.AcknowledgeAsync` — no positive-trip test, no wrapped-pass test.
- `IHistoryProvider.ReadEventsAsync` wrapped inside `CapabilityInvoker.ExecuteAsync` — DIM trip is tested (`Direct_ReadEventsAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic`), but the corresponding pass-clean case (wrapped DIM call) is absent, leaving the suppression path for `ReadEventsAsync` unexercised.
The analyzer is interface-typed and applies the same logic uniformly to every guarded-interface member, so these gaps do not hide a latent bug in the current code. However, missing positive-trip tests for three async guarded methods means a future regression (e.g. accidentally removing a metadata name from `GuardedInterfaceMetadataNames`) would not be caught by the test suite for those methods.
**Recommendation:** Add one positive-trip test and one pass-clean test for each of the three untested async methods, and one wrapped pass-clean test for `ReadEventsAsync`. All four methods already appear in `StubSources` so no stub changes are required.
**Resolution:** Resolved 2026-06-19 (SHA blank) — Added five regression tests: `Direct_UnsubscribeAsync_Call_TripsDiagnostic`, `Direct_UnsubscribeAlarmsAsync_Call_TripsDiagnostic`, `Direct_AcknowledgeAsync_Call_TripsDiagnostic`, `Wrapped_UnsubscribeAsync_InsideCapabilityInvokerLambda_PassesCleanly`, and `Wrapped_ReadEventsAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly`. All five pass against the existing analyzer with no source changes.
@@ -833,6 +833,129 @@ namespace ZB.MOM.WW.OtOpcUa.Server {
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) };