Roslyn analyzer — detect unwrapped driver-capability calls (OTOPCUA0001). Closes task #200. New netstandard2.0 analyzer project src/ZB.MOM.WW.OtOpcUa.Analyzers registered as an <Analyzer>-item ProjectReference from the Server csproj so the warning fires at every Server compile. First (and only so far) rule OTOPCUA0001 — "Driver capability call must be wrapped in CapabilityInvoker" — walks every InvocationOperation in the AST + trips when (a) the target method implements one of the seven guarded capability interfaces (IReadable / IWritable / ITagDiscovery / ISubscribable / IHostConnectivityProbe / IAlarmSource / IHistoryProvider) AND (b) the method's return type is Task, Task<T>, ValueTask, or ValueTask<T> — the async-wire-call constraint narrows the rule to the surfaces the Phase 6.1 pipeline actually wraps + sidesteps pure in-memory accessors like IHostConnectivityProbe.GetHostStatuses() which would trigger false positives AND (c) the call does NOT sit inside a lambda argument passed to CapabilityInvoker.ExecuteAsync / ExecuteWriteAsync / AlarmSurfaceInvoker.*. The wrapper detection walks up the syntax tree from the call site, finds any enclosing InvocationExpressionSyntax whose method's containing type is one of the wrapper classes, + verifies the call lives transitively inside that invocation's AnonymousFunctionExpressionSyntax argument — a sibling "result = await driver.ReadAsync(...)" followed by a separate invoker.ExecuteAsync(...) call does NOT satisfy the wrapping rule + the analyzer flags it (regression guard in the 5th test). Five xunit-v3 + Shouldly tests at tests/ZB.MOM.WW.OtOpcUa.Analyzers.Tests: direct ReadAsync in server namespace trips; wrapped ReadAsync inside CapabilityInvoker.ExecuteAsync lambda passes; direct WriteAsync trips; direct DiscoverAsync trips; sneaky pattern — read outside the lambda + ExecuteAsync with unrelated lambda nearby — still trips. Hand-rolled test harness compiles a stub-plus-user snippet via CSharpCompilation.WithAnalyzers + runs GetAnalyzerDiagnosticsAsync directly, deliberately avoiding Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit because that package pins to xunit v2 + this repo is on xunit.v3 everywhere else. RS2008 release-tracking noise suppressed by adding AnalyzerReleases.Shipped.md + AnalyzerReleases.Unshipped.md as AdditionalFiles, which is the canonical Roslyn-analyzer hygiene path. Analyzer DLL referenced from Server.csproj via ProjectReference with OutputItemType=Analyzer + ReferenceOutputAssembly=false — the DLL ships as a compiler plugin, not a runtime dependency. Server build validates clean: the analyzer activates on every Server file but finds zero violations, which confirms the Phase 6.1 wrapping work done in prior PRs is complete + the analyzer is now the regression guard preventing the next new capability surface from being added raw. slnx updated with both the src + tests project entries. Full solution build clean, analyzer suite 5/5 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Analyzers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Analyzers.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Compile-a-snippet-and-run-the-analyzer tests. Avoids
|
||||
/// Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit because it pins to xunit v2 +
|
||||
/// this project uses xunit.v3 like the rest of the solution. Hand-rolled harness is 15
|
||||
/// lines + makes the assertion surface obvious at the test-author level.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnwrappedCapabilityCallAnalyzerTests
|
||||
{
|
||||
/// <summary>Minimal stubs for the guarded interfaces + the two wrapper types. Keeps the
|
||||
/// analyzer tests independent of the real OtOpcUa project references so a drift in those
|
||||
/// signatures doesn't secretly mute the analyzer check.</summary>
|
||||
private const string StubSources = """
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions {
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
public interface IReadable {
|
||||
ValueTask<IReadOnlyList<object>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct);
|
||||
}
|
||||
public interface IWritable {
|
||||
ValueTask WriteAsync(IReadOnlyList<object> ops, CancellationToken ct);
|
||||
}
|
||||
public interface ITagDiscovery {
|
||||
Task DiscoverAsync(CancellationToken ct);
|
||||
}
|
||||
public enum DriverCapability { Read, Write, Discover }
|
||||
}
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience {
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
public sealed class CapabilityInvoker {
|
||||
public ValueTask<T> ExecuteAsync<T>(DriverCapability c, string host, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct) => throw null!;
|
||||
public ValueTask ExecuteAsync(DriverCapability c, string host, Func<CancellationToken, ValueTask> call, CancellationToken ct) => throw null!;
|
||||
public ValueTask<T> ExecuteWriteAsync<T>(string host, bool isIdempotent, Func<CancellationToken, ValueTask<T>> call, CancellationToken ct) => throw null!;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Direct_ReadAsync_Call_InServerNamespace_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 BadCaller {
|
||||
public async Task DoIt(IReadable driver) {
|
||||
var _ = await driver.ReadAsync(new List<string>(), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var diags = await Compile(userSrc);
|
||||
diags.Length.ShouldBe(1);
|
||||
diags[0].Id.ShouldBe(UnwrappedCapabilityCallAnalyzer.DiagnosticId);
|
||||
diags[0].GetMessage().ShouldContain("ReadAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrapped_ReadAsync_InsideCapabilityInvokerLambda_PassesCleanly()
|
||||
{
|
||||
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.Server {
|
||||
public sealed class GoodCaller {
|
||||
public async Task DoIt(IReadable driver, CapabilityInvoker invoker) {
|
||||
var _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1",
|
||||
async ct => await driver.ReadAsync(new List<string>(), ct), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var diags = await Compile(userSrc);
|
||||
diags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectWrite_WithoutWrapper_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 BadWrite {
|
||||
public async Task DoIt(IWritable driver) {
|
||||
await driver.WriteAsync(new List<object>(), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var diags = await Compile(userSrc);
|
||||
diags.Length.ShouldBe(1);
|
||||
diags[0].GetMessage().ShouldContain("WriteAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_Call_WithoutWrapper_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 BadDiscover {
|
||||
public async Task DoIt(ITagDiscovery driver) {
|
||||
await driver.DiscoverAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var diags = await Compile(userSrc);
|
||||
diags.Length.ShouldBe(1);
|
||||
diags[0].GetMessage().ShouldContain("DiscoverAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Call_OutsideOfLambda_ButInsideInvokerCall_StillTripsDiagnostic()
|
||||
{
|
||||
// Precompute the read *outside* the lambda, then pass the awaited result — that does NOT
|
||||
// actually wrap the ReadAsync call in the resilience pipeline, so the analyzer must
|
||||
// still flag it (regression guard: a naive "any mention of ExecuteAsync nearby" rule
|
||||
// would silently let this pattern through).
|
||||
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.Server {
|
||||
public sealed class SneakyCaller {
|
||||
public async Task DoIt(IReadable driver, CapabilityInvoker invoker) {
|
||||
var result = await driver.ReadAsync(new List<string>(), CancellationToken.None); // not inside any lambda
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "h1",
|
||||
async ct => { await Task.Yield(); }, CancellationToken.None);
|
||||
_ = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var diags = await Compile(userSrc);
|
||||
diags.Length.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task<ImmutableArray<Diagnostic>> Compile(string userSource)
|
||||
{
|
||||
var syntaxTrees = new[]
|
||||
{
|
||||
CSharpSyntaxTree.ParseText(StubSources),
|
||||
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",
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user