# Code Review — Analyzers

| Field | Value |
|---|---|
| Module | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 0 |

## Checklist coverage

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Analyzers-001, Analyzers-002 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | No issues found |
| 4 | Error handling & resilience | Analyzers-003 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Analyzers-004 |
| 7 | Design-document adherence | Analyzers-005 |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Analyzers-006 |
| 10 | Documentation & comments | Analyzers-007 |

## Findings

### Analyzers-001

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:135-139` |
| Status | Resolved |

**Description:** `IsInsideWrapperLambda` treats a guarded call as "wrapped" if it is textually inside ANY lambda that is an argument to ANY invocation whose containing type is `CapabilityInvoker` or `AlarmSurfaceInvoker`. It matches the containing type only, never the parameter the lambda is bound to. The real wrapping contract is specifically the `callSite` (`Func<CancellationToken, ValueTask>` / `Func<CancellationToken, ValueTask<T>>`) parameter of `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync`. Any other lambda argument to a method on those types — a future overload that takes a predicate/selector lambda, or a lambda passed in a non-`callSite` position — would suppress the diagnostic even though the guarded call is not actually executed inside the resilience pipeline. The analyzer's own XML doc (lines 21-23) describes exactly this looser-than-intended behaviour. It is a latent false-negative gap rather than an active bug because the current `CapabilityInvoker` surface has no non-`callSite` lambda parameter.

**Recommendation:** Resolve the symbol of the lambda argument's parameter (`IMethodSymbol.Parameters[i]`) and require its type to be the `Func<CancellationToken, ValueTask>` / `Func<CancellationToken, ValueTask<T>>` callsite shape, or at minimum match the wrapper method name (`ExecuteAsync` / `ExecuteWriteAsync`) rather than only the containing type. This closes the gap before a new overload silently widens the escape hatch.

**Resolution:** Resolved 2026-05-22 — Replaced `WrapperTypes` string array with `WrapperMethods` (type FQN + method name) tuples so `IsInsideWrapperLambda` matches both containing type and method name, preventing future non-`callSite` overloads from silently suppressing the diagnostic.

### Analyzers-002

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:46-50,130` |
| Status | Resolved |

**Description:** `AlarmSurfaceInvoker` is listed in `WrapperTypes`, but `AlarmSurfaceInvoker`'s public methods (`SubscribeAsync`, `UnsubscribeAsync`, `AcknowledgeAsync`) take no lambda arguments at all — callers pass `IReadOnlyList<...>` / `IAlarmSubscriptionHandle`, and the invoker builds the resilience lambdas internally. `IsInsideWrapperLambda` only ever returns `true` when it finds an `AnonymousFunctionExpressionSyntax` argument in the outer call's argument list. Because no `AlarmSurfaceInvoker` call site can have a lambda argument, the `AlarmSurfaceInvoker` entry in `WrapperTypes` is effectively dead — it can never satisfy the suppression condition. Guarded `IAlarmSource` calls written inside `AlarmSurfaceInvoker.cs` are in fact suppressed correctly, but only because they sit inside `CapabilityInvoker.ExecuteAsync` lambdas (the `CapabilityInvoker` entry does the work). The dead entry is misleading and suggests the analyzer recognises an `AlarmSurfaceInvoker` "lambda home" that does not exist.

**Recommendation:** Either remove `AlarmSurfaceInvoker` from `WrapperTypes` (its calls are already covered transitively by the `CapabilityInvoker` match) and update the XML doc, or — if the intent is to allow `IAlarmSource` calls anywhere inside `AlarmSurfaceInvoker` regardless of lambda nesting — add an explicit "call site is lexically within the `AlarmSurfaceInvoker` type declaration" check rather than relying on a lambda-argument scan that never fires.

**Resolution:** Resolved 2026-05-23 — Removed the three dead `AlarmSurfaceInvoker` entries from `WrapperMethodKeys` (renamed from `WrapperMethods`); calls inside `AlarmSurfaceInvoker` methods remain correctly suppressed via the transitive `CapabilityInvoker.ExecuteAsync` lambda match. Updated XML docs + diagnostic message to drop the misleading `AlarmSurfaceInvoker.*` reference. Pinned the transitive coverage with regression test `GuardedCall_InsideAlarmSurfaceInvokerMethod_WrappedByCapabilityInvoker_PassesCleanly`.

### Analyzers-003

| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:80,114-116` |
| Status | Resolved |

**Description:** `IsInsideWrapperLambda` is passed `context.Operation.SemanticModel` and returns `false` when that model is `null`. A `false` return means "not wrapped", so a null semantic model produces a false-positive diagnostic rather than silently skipping the call. For `RegisterOperationAction` the `SemanticModel` is non-null in normal compilation, so this is low-risk in practice, but the failure mode is the wrong direction — a tooling/IDE edge case where the model is unavailable would flag correct code. Separately, the analyzer has no defensive guard against partially-bound / malformed call sites: `method.ContainingType`, `method.ReturnType`, and `iface.GetMembers()` are dereferenced without null checks. `IInvocationOperation.TargetMethod` is non-null by contract and `ContainingType` is non-null for an ordinary method, so a hard crash is unlikely, but an analyzer that throws on malformed in-progress syntax degrades the IDE experience for the whole solution.

**Recommendation:** When `semanticModel is null` in `AnalyzeInvocation`, return early (skip the call) instead of letting `IsInsideWrapperLambda` report it as unwrapped, so unavailable semantics never produce a false positive.

**Resolution:** Resolved 2026-05-23 — `AnalyzeInvocation` now returns early when `context.Operation.SemanticModel is null` instead of reporting the call as unwrapped; added defensive null guards for `TargetMethod`, `ContainingType`, and `ReturnType` so an analyzer crash on partially-bound IDE syntax cannot leak. The `MethodImplementsInterfaceMember` helper iterates members filtered by `is IMethodSymbol` so non-method members never throw on `.GetMembers()` dereferences.

### Analyzers-004

| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:95-112` |
| Status | Resolved |

**Description:** `ImplementsGuardedInterface` runs on every invocation operation in the compilation (every keystroke in the IDE). For each candidate it allocates via `AllInterfaces.Concat(new[] { method.ContainingType })`, builds a fully-qualified display string per interface and calls `string.Replace("global::", ...)`, then for matching interfaces iterates `iface.GetMembers().OfType<IMethodSymbol>()` calling `FindImplementationForInterfaceMember` per member. The `GuardedInterfaces` / `WrapperTypes` lookups are `string[].Contains` (linear scan) rather than a hash set. None of this is catastrophic — the interface sets are tiny — but the work is repeated for every invocation including the overwhelming majority that target non-guarded methods, and the FQN string formatting plus `Replace` allocation on the hot path is avoidable.

**Recommendation:** Move to `RegisterCompilationStartAction`: resolve the guarded interface and wrapper-type symbols once via `Compilation.GetTypeByMetadataName`, capture them, and compare invocation symbols by `SymbolEqualityComparer` identity. Replace the `string[]` membership checks with a `HashSet`. This also makes the analyzer correctly no-op in compilations that do not reference `Core.Abstractions`.

**Resolution:** Resolved 2026-05-23 — Refactored to `RegisterCompilationStartAction` that resolves all guarded-interface and wrapper-method symbols once via `Compilation.GetTypeByMetadataName`, stores them in `HashSet<INamedTypeSymbol>` / `Dictionary<INamedTypeSymbol, HashSet<string>>` using `SymbolEqualityComparer.Default`, then registers the per-invocation action only when guarded types are present (the analyzer is a no-op when none are referenced). The hot path now uses `SymbolEqualityComparer.Default.Equals` instead of FQN-string formatting + `Replace`. Pinned the cold-compilation no-op with regression test `Compilation_WithoutGuardedInterfaceReferences_EmitsNoDiagnostics`.

### Analyzers-005

| Field | Value |
|---|---|
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:33-43` |
| Status | Resolved |

**Description:** `CapabilityInvoker`'s XML doc (`src/Core/.../Resilience/CapabilityInvoker.cs:15-17`) enumerates the routed capability surface as `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, and all four `IHistoryProvider` reads — matching the analyzer's `GuardedInterfaces` set. However `IHistoryProvider` exposes five async methods, and two of them (`ReadAtTimeAsync`, `ReadEventsAsync`) are C# default-interface-method implementations. When a driver does not override a DIM and a caller invokes it through a concrete driver reference, `FindImplementationForInterfaceMember` returns the interface's own default method symbol; the second equality branch (`method.OriginalDefinition` == `member`) still catches the interface-typed-receiver case, so detection holds — but this DIM interaction is undocumented and untested, and a future driver that overrides one DIM but not the other creates an asymmetric guarded surface that nobody has verified.

**Recommendation:** Add explicit test cases (see Analyzers-006) for `IHistoryProvider` calls via both an interface-typed receiver and a concrete driver that (a) overrides and (b) inherits the default `ReadAtTimeAsync` / `ReadEventsAsync`. If a gap is found, handle DIM members explicitly. Add a short remark to the analyzer XML doc noting the default-interface-method consideration.

**Resolution:** Resolved 2026-05-23 — Extended the test stub `IHistoryProvider` with both DIM members (`ReadAtTimeAsync` + `ReadEventsAsync`) and added four regression tests pinning the DIM behaviour for (a) concrete driver inheriting the DIM (`Direct_ReadAtTimeAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic`, `Direct_ReadEventsAsync_OnConcreteDriverInheritingDIM_TripsDiagnostic`), (b) concrete driver overriding the DIM (`Direct_ReadAtTimeAsync_OnConcreteDriverOverridingDIM_TripsDiagnostic`), and (c) DIM call correctly wrapped (`Wrapped_ReadAtTimeAsync_DIM_InsideCapabilityInvokerLambda_PassesCleanly`). Confirmed no gap: both DIM branches (interface-typed receiver routing to `method.OriginalDefinition == member`, concrete-receiver-with-override routing to `FindImplementationForInterfaceMember`) match correctly. Added a dedicated DIM paragraph to the analyzer's XML remarks.

### Analyzers-006

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Testing coverage |
| Location | `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs` |
| Status | Resolved |

**Description:** The test suite exercises only 3 of the 7 guarded interfaces (`IReadable`, `IWritable`, `ITagDiscovery`) and one positive / one negative lambda case. Significant untested behaviour for an analyzer that gates a repo-wide resilience invariant:

- No test for `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, or `IHistoryProvider` — four of seven guarded interfaces, including the two (`IAlarmSource`, `IHistoryProvider`) with the most subtle wrapping story.
- No test that a synchronous guarded-type member is NOT flagged — `IHostConnectivityProbe.GetHostStatuses()` is explicitly called out in the source comment (lines 75-77) as something the `IsAsyncReturningType` filter must let through, yet there is no regression test pinning that.
- No test for a concrete driver class implementing the interface (the receiver is always the interface type `IReadable driver`); the `FindImplementationForInterfaceMember` branch of `ImplementsGuardedInterface` — the entire reason the source comment claims an unusually-named method implementing `IReadable.ReadAsync` still trips the rule — is never executed by a test.
- No test for `ExecuteWriteAsync` (only `ExecuteAsync` is covered) and no test for `AlarmSurfaceInvoker`.
- No test for nested lambdas or for the generated-code exclusion (`ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None)`).
- The `StubSources` constant omits `ISubscribable` / `IAlarmSource` / `IHistoryProvider` / `IHostConnectivityProbe` and `AlarmSurfaceInvoker` entirely, so those paths cannot be tested without extending it.

**Recommendation:** Extend `StubSources` with the remaining guarded interfaces and `AlarmSurfaceInvoker`, then add tests for: each remaining guarded interface (positive plus wrapped), a synchronous member not being flagged, a concrete driver-class receiver with a renamed implementing method, `ExecuteWriteAsync` wrapping, and a nested-lambda case.

**Resolution:** Resolved 2026-05-22 — Extended `StubSources` with `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`, and `AlarmSurfaceInvoker` stubs; added 14 new tests covering each missing guarded interface (positive + wrapped), synchronous member not flagged, concrete driver receiver, `ExecuteWriteAsync` wrapping, and nested-lambda cases (19 tests total, all passing).

### Analyzers-007

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs:21-26` |
| Status | Resolved |

**Description:** The `<remarks>` block states the analyzer "matches by receiver-interface identity using Roslyn's semantic model, not by method name". This is accurate for the guarded-call detection (`ImplementsGuardedInterface` uses symbols), but the wrapper detection in `IsInsideWrapperLambda` is described in the same block as walking the syntax tree and checking enclosing invocations by containing type — and that detection is in fact looser than the prose implies (see Analyzers-001): it does not verify the lambda is bound to the resilience `callSite` parameter. The XML doc reads as if the wrapper match is precise. The `<remarks>` also notes the rule does not enforce the capability argument matches the method, but omits the more important current limitation — that a lambda in any argument position of a wrapper-typed call suppresses the diagnostic.

**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.
