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:
@@ -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.
|
||||
|
||||
+123
@@ -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) };
|
||||
|
||||
Reference in New Issue
Block a user