Compare commits
4 Commits
diff-acl-s
...
roslyn-ana
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c0d3154c1 | ||
| 74067e7d7e | |||
|
|
ef53553e9d | ||
| d1e50db304 |
@@ -18,6 +18,7 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
||||||
@@ -42,5 +43,6 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/ZB.MOM.WW.OtOpcUa.Analyzers.Tests.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
@@ -70,6 +71,19 @@ builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
|||||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||||
|
|
||||||
|
// OpenTelemetry Prometheus exporter — Meter stream from RedundancyMetrics + any future
|
||||||
|
// Admin-side instrumentation lands on the /metrics endpoint Prometheus scrapes. Pull-based
|
||||||
|
// means no OTel Collector deployment required for the common deploy-in-a-K8s case; appsettings
|
||||||
|
// Metrics:Prometheus:Enabled=false disables the endpoint entirely for locked-down deployments.
|
||||||
|
var metricsEnabled = builder.Configuration.GetValue("Metrics:Prometheus:Enabled", true);
|
||||||
|
if (metricsEnabled)
|
||||||
|
{
|
||||||
|
builder.Services.AddOpenTelemetry()
|
||||||
|
.WithMetrics(m => m
|
||||||
|
.AddMeter(RedundancyMetrics.MeterName)
|
||||||
|
.AddPrometheusExporter());
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
@@ -87,6 +101,15 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
|||||||
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||||
app.MapHub<AlertHub>("/hubs/alerts");
|
app.MapHub<AlertHub>("/hubs/alerts");
|
||||||
|
|
||||||
|
if (metricsEnabled)
|
||||||
|
{
|
||||||
|
// Prometheus scrape endpoint — expose instrumentation registered in the OTel MeterProvider
|
||||||
|
// above. Emits text-format metrics at /metrics; auth is intentionally NOT required (Prometheus
|
||||||
|
// scrape jobs typically run on a trusted network). Operators who need auth put the endpoint
|
||||||
|
// behind a reverse-proxy basic-auth gate per fleet-ops convention.
|
||||||
|
app.MapPrometheusScrapingEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.2-beta.1"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -23,5 +23,10 @@
|
|||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
|
},
|
||||||
|
"Metrics": {
|
||||||
|
"Prometheus": {
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/ZB.MOM.WW.OtOpcUa.Analyzers/AnalyzerReleases.Shipped.md
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Analyzers/AnalyzerReleases.Shipped.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
; Shipped analyzer releases.
|
||||||
|
; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||||
|
|
||||||
|
## Release 1.0
|
||||||
|
|
||||||
|
### New Rules
|
||||||
|
|
||||||
|
Rule ID | Category | Severity | Notes
|
||||||
|
--------|----------|----------|-------
|
||||||
|
OTOPCUA0001 | OtOpcUa.Resilience | Warning | Direct driver-capability call bypasses CapabilityInvoker
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
; Unshipped analyzer release.
|
||||||
|
; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Diagnostics;
|
||||||
|
using Microsoft.CodeAnalysis.Operations;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Analyzers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic analyzer that flags direct invocations of Phase 6.1-wrapped driver-capability
|
||||||
|
/// methods when the call is NOT already running inside a <c>CapabilityInvoker.ExecuteAsync</c>,
|
||||||
|
/// <c>CapabilityInvoker.ExecuteWriteAsync</c>, or <c>AlarmSurfaceInvoker.*Async</c> lambda.
|
||||||
|
/// The wrapping is what gives us per-host breaker isolation, retry semantics, bulkhead-depth
|
||||||
|
/// accounting, and alarm-ack idempotence guards — raw calls bypass all of that.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The analyzer matches by receiver-interface identity using Roslyn's semantic model, not by
|
||||||
|
/// method name, so a driver with an unusually-named method implementing <c>IReadable.ReadAsync</c>
|
||||||
|
/// still trips the rule. Lambda-context detection walks up the syntax tree from the call site
|
||||||
|
/// + checks whether any enclosing <c>InvocationExpressionSyntax</c> targets a member whose
|
||||||
|
/// containing type is <c>CapabilityInvoker</c> or <c>AlarmSurfaceInvoker</c>. The rule is
|
||||||
|
/// intentionally narrow: it does NOT try to enforce the capability argument matches the
|
||||||
|
/// method (e.g. ReadAsync wrapped in <c>ExecuteAsync(DriverCapability.Write, ...)</c> still
|
||||||
|
/// passes) — that'd require flow analysis beyond single-expression scope.
|
||||||
|
/// </remarks>
|
||||||
|
[DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
||||||
|
public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||||
|
{
|
||||||
|
public const string DiagnosticId = "OTOPCUA0001";
|
||||||
|
|
||||||
|
/// <summary>Interfaces whose methods must be called through the capability invoker.</summary>
|
||||||
|
private static readonly string[] GuardedInterfaces =
|
||||||
|
[
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IWritable",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ITagDiscovery",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ISubscribable",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHostConnectivityProbe",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmSource",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Wrapper types whose lambda arguments are the allowed home for guarded calls.</summary>
|
||||||
|
private static readonly string[] WrapperTypes =
|
||||||
|
[
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly DiagnosticDescriptor Rule = new(
|
||||||
|
id: DiagnosticId,
|
||||||
|
title: "Driver capability call must be wrapped in CapabilityInvoker",
|
||||||
|
messageFormat: "Call to '{0}' is not wrapped in CapabilityInvoker.ExecuteAsync / ExecuteWriteAsync / AlarmSurfaceInvoker.*. Without the wrapping, Phase 6.1 resilience (retry, breaker, bulkhead, tracker telemetry) is bypassed for this call.",
|
||||||
|
category: "OtOpcUa.Resilience",
|
||||||
|
defaultSeverity: DiagnosticSeverity.Warning,
|
||||||
|
isEnabledByDefault: true,
|
||||||
|
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource/IHistoryProvider call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
|
||||||
|
|
||||||
|
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
|
||||||
|
|
||||||
|
public override void Initialize(AnalysisContext context)
|
||||||
|
{
|
||||||
|
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||||
|
context.EnableConcurrentExecution();
|
||||||
|
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
||||||
|
{
|
||||||
|
var invocation = (Microsoft.CodeAnalysis.Operations.IInvocationOperation)context.Operation;
|
||||||
|
var method = invocation.TargetMethod;
|
||||||
|
|
||||||
|
// Narrow the rule to async wire calls. Synchronous accessors like
|
||||||
|
// IHostConnectivityProbe.GetHostStatuses() are pure in-memory snapshots + would never
|
||||||
|
// benefit from the Polly pipeline; flagging them just creates false-positives.
|
||||||
|
if (!IsAsyncReturningType(method.ReturnType)) return;
|
||||||
|
if (!ImplementsGuardedInterface(method)) return;
|
||||||
|
if (IsInsideWrapperLambda(invocation.Syntax, context.Operation.SemanticModel, context.CancellationToken)) return;
|
||||||
|
|
||||||
|
var diag = Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), $"{method.ContainingType.Name}.{method.Name}");
|
||||||
|
context.ReportDiagnostic(diag);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAsyncReturningType(ITypeSymbol type)
|
||||||
|
{
|
||||||
|
var name = type.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||||
|
return name is "global::System.Threading.Tasks.Task"
|
||||||
|
or "global::System.Threading.Tasks.Task<TResult>"
|
||||||
|
or "global::System.Threading.Tasks.ValueTask"
|
||||||
|
or "global::System.Threading.Tasks.ValueTask<TResult>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ImplementsGuardedInterface(IMethodSymbol method)
|
||||||
|
{
|
||||||
|
foreach (var iface in method.ContainingType.AllInterfaces.Concat(new[] { method.ContainingType }))
|
||||||
|
{
|
||||||
|
var ifaceFqn = iface.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||||
|
.Replace("global::", string.Empty);
|
||||||
|
if (!GuardedInterfaces.Contains(ifaceFqn)) continue;
|
||||||
|
|
||||||
|
foreach (var member in iface.GetMembers().OfType<IMethodSymbol>())
|
||||||
|
{
|
||||||
|
var impl = method.ContainingType.FindImplementationForInterfaceMember(member);
|
||||||
|
if (SymbolEqualityComparer.Default.Equals(impl, method) ||
|
||||||
|
SymbolEqualityComparer.Default.Equals(method.OriginalDefinition, member))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInsideWrapperLambda(SyntaxNode startNode, SemanticModel? semanticModel, System.Threading.CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (semanticModel is null) return false;
|
||||||
|
|
||||||
|
for (var node = startNode.Parent; node is not null; node = node.Parent)
|
||||||
|
{
|
||||||
|
// We only care about an enclosing invocation — the call we're auditing must literally
|
||||||
|
// live inside a lambda (ParenthesizedLambda / SimpleLambda / AnonymousMethod) that is
|
||||||
|
// an argument of a CapabilityInvoker.Execute* / AlarmSurfaceInvoker.* call.
|
||||||
|
if (node is not InvocationExpressionSyntax outer) continue;
|
||||||
|
|
||||||
|
var sym = semanticModel.GetSymbolInfo(outer, ct).Symbol as IMethodSymbol;
|
||||||
|
if (sym is null) continue;
|
||||||
|
|
||||||
|
var outerTypeFqn = sym.ContainingType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||||
|
.Replace("global::", string.Empty);
|
||||||
|
if (!WrapperTypes.Contains(outerTypeFqn)) continue;
|
||||||
|
|
||||||
|
// The call is wrapped IFF our startNode is transitively inside one of the outer
|
||||||
|
// call's argument lambdas. Walk the outer invocation's argument list + check whether
|
||||||
|
// any lambda body contains the startNode's position.
|
||||||
|
foreach (var arg in outer.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
if (arg.Expression is not AnonymousFunctionExpressionSyntax lambda) continue;
|
||||||
|
if (lambda.Span.Contains(startNode.Span)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Roslyn analyzers ship as netstandard2.0 so they load into the MSBuild compiler host
|
||||||
|
(which on .NET Framework 4.7.2 and .NET 6+ equally resolves netstandard2.0). -->
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Analyzers</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AdditionalFiles Include="AnalyzerReleases.Shipped.md"/>
|
||||||
|
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||||
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Analyzers.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user