Skip to content

Commit 0a62ec6

Browse files
feat(sdk): Plugin SDK E — Roslyn analyzers (3 live, 7 stub) (#19)
10 SRWV-prefix descriptors registriert. 3 davon end-to-end mit Tests, 7 als enabled-by-default=false Stubs reserviert fuer follow-up. Eigenes NuGet (analyzers/dotnet/cs/), wird transitively durch das Plugin SDK gezogen. Live + getestet: - SRWV001 PluginShouldBeSealedAnalyzer (Warning) Plugin-Class soll sealed sein — Plugin-Loader instanziert per Activator.CreateInstance, Subclassing hat keinen Use-Case. - SRWV004 ParameterlessCtorRequiredAnalyzer (Error) Mind. ein parameterless ctor erforderlich. Compiler-default-ctor (kein expliziter ctor) zaehlt. Nur Plugin-Interfaces betroffen. - SRWV010 LoggerOverConsoleAnalyzer (Warning) Console.Write/WriteLine in Plugin-Class -> rot. Bypassed ILogger-Pipeline (Log-Levels, structured logging, OTel-Correlation). Nur innerhalb Plugin-Class-Scope; Utility-Code ausserhalb bleibt frei. Stubs (descriptors registriert, kein Code, isEnabledByDefault=false): - SRWV002 Configure must not block - SRWV003 manifest assemblies entry must resolve - SRWV005 plugin id uniqueness - SRWV006 XML doc on public API - SRWV007 CancellationToken on async lifecycle - SRWV008 no Surgewave-internals import - SRWV009 manifest version matches package version Test-Coverage (9/9 gruen): - SRWV001: unsealed plugin fires; sealed quiet; non-plugin class quiet. - SRWV004: only-param-ctor fires; default-compiler-ctor quiet; both-ctors quiet. - SRWV010: Console.WriteLine inside plugin fires; Console.Write same; utility class outside plugin quiet. Closes #19.
1 parent c94da81 commit 0a62ec6

11 files changed

Lines changed: 661 additions & 0 deletions

Kuestenlogik.Surgewave.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@
3030
<Project Path="src/Kuestenlogik.Surgewave.Storage.Engine.Sqlite/Kuestenlogik.Surgewave.Storage.Engine.Sqlite.csproj" />
3131
<Project Path="src/Kuestenlogik.Surgewave.Storage.Tiering/Kuestenlogik.Surgewave.Storage.Tiering.csproj" />
3232
<Project Path="src/Kuestenlogik.Surgewave.Storage.Disaggregated/Kuestenlogik.Surgewave.Storage.Disaggregated.csproj" />
33+
<Project Path="src/Kuestenlogik.Surgewave.Analyzers/Kuestenlogik.Surgewave.Analyzers.csproj" />
3334
<Project Path="tests/Kuestenlogik.Surgewave.Storage.Disaggregated.Tests/Kuestenlogik.Surgewave.Storage.Disaggregated.Tests.csproj" />
3435
<Project Path="tests/Kuestenlogik.Surgewave.Build.Tests/Kuestenlogik.Surgewave.Build.Tests.csproj" />
36+
<Project Path="tests/Kuestenlogik.Surgewave.Analyzers.Tests/Kuestenlogik.Surgewave.Analyzers.Tests.csproj" />
3537
<Project Path="templates/Kuestenlogik.Surgewave.Templates/Kuestenlogik.Surgewave.Templates.csproj" />
3638
<Project Path="src/Kuestenlogik.Surgewave.Clustering/Kuestenlogik.Surgewave.Clustering.csproj" />
3739
<Project Path="src/Kuestenlogik.Surgewave.Broker/Kuestenlogik.Surgewave.Broker.csproj" />
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>disable</ImplicitUsings>
8+
<AssemblyName>Kuestenlogik.Surgewave.Analyzers</AssemblyName>
9+
<RootNamespace>Kuestenlogik.Surgewave.Analyzers</RootNamespace>
10+
<Description>Roslyn analyzers for Surgewave plugin authors. SRWV001..SRWV010 catch the most common authoring mistakes (missing sealed, blocking Configure, Console.WriteLine instead of ILogger, ...) at build time before the .swpkg is packed. Shipped as its own NuGet; consumed transitively via the Surgewave plugin SDK.</Description>
11+
<PackageTags>surgewave;analyzer;roslyn;plugins;sdk</PackageTags>
12+
<IsPackable>true</IsPackable>
13+
<DevelopmentDependency>true</DevelopmentDependency>
14+
<IncludeBuildOutput>false</IncludeBuildOutput>
15+
<NoPackageAnalysis>true</NoPackageAnalysis>
16+
<!-- RS2008: release tracking is overkill while the rule set is in flux; revisit before 1.0. -->
17+
<NoWarn>$(NoWarn);NU5128;RS1041;RS2008</NoWarn>
18+
<!-- Analyzers ship under analyzers/dotnet/cs/ inside the nupkg.
19+
No build/ targets needed — NuGet picks them up automatically. -->
20+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
21+
</PropertyGroup>
22+
23+
<ItemGroup>
24+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp">
25+
<PrivateAssets>all</PrivateAssets>
26+
</PackageReference>
27+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers">
28+
<PrivateAssets>all</PrivateAssets>
29+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
30+
</PackageReference>
31+
</ItemGroup>
32+
33+
<ItemGroup>
34+
<None Include="$(OutputPath)\$(AssemblyName).dll"
35+
Pack="true"
36+
PackagePath="analyzers/dotnet/cs/"
37+
Visible="false"
38+
Condition="Exists('$(OutputPath)\$(AssemblyName).dll')" />
39+
</ItemGroup>
40+
41+
</Project>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
using Microsoft.CodeAnalysis.Diagnostics;
5+
using Microsoft.CodeAnalysis.Operations;
6+
7+
namespace Kuestenlogik.Surgewave.Analyzers;
8+
9+
/// <summary>
10+
/// SRWV010: <c>System.Console</c> writes inside a plugin class bypass the
11+
/// broker's logging pipeline (levels, structured fields, OTel correlation,
12+
/// sink routing). Inject <c>ILogger&lt;T&gt;</c> via the constructor and
13+
/// log through that instead. Triggered for <c>WriteLine</c>, <c>Write</c>,
14+
/// <c>Error.WriteLine</c>, <c>Error.Write</c>.
15+
/// </summary>
16+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
17+
public sealed class LoggerOverConsoleAnalyzer : DiagnosticAnalyzer
18+
{
19+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
20+
ImmutableArray.Create(SurgewaveDiagnostics.SRWV010_LoggerOverConsole);
21+
22+
public override void Initialize(AnalysisContext context)
23+
{
24+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
25+
context.EnableConcurrentExecution();
26+
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
27+
}
28+
29+
private static void AnalyzeInvocation(OperationAnalysisContext context)
30+
{
31+
var invocation = (IInvocationOperation)context.Operation;
32+
var target = invocation.TargetMethod;
33+
if (target.ContainingType?.ToDisplayString() != "System.Console") return;
34+
if (target.Name != "WriteLine" && target.Name != "Write") return;
35+
36+
// Only flag the call when it sits inside a class that implements a
37+
// plugin interface — otherwise this rule would shout at every utility
38+
// assembly that happens to be in the analyzer's reach.
39+
var enclosingType = GetEnclosingType(context.ContainingSymbol);
40+
if (enclosingType is null) return;
41+
if (!PluginShouldBeSealedAnalyzer.ImplementsPluginInterface(enclosingType)) return;
42+
43+
var diag = Diagnostic.Create(
44+
SurgewaveDiagnostics.SRWV010_LoggerOverConsole,
45+
invocation.Syntax.GetLocation());
46+
context.ReportDiagnostic(diag);
47+
}
48+
49+
private static INamedTypeSymbol? GetEnclosingType(ISymbol? symbol)
50+
{
51+
while (symbol is not null)
52+
{
53+
if (symbol is INamedTypeSymbol named) return named;
54+
symbol = symbol.ContainingSymbol;
55+
}
56+
return null;
57+
}
58+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Collections.Immutable;
2+
using System.Linq;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.Diagnostics;
5+
6+
namespace Kuestenlogik.Surgewave.Analyzers;
7+
8+
/// <summary>
9+
/// SRWV004: a plugin class must expose a parameterless constructor
10+
/// because <c>BrokerPluginActivator</c> uses <c>Activator.CreateInstance</c>
11+
/// with no arguments. Defaulted ctors count; the rule only fires when
12+
/// every declared ctor requires arguments.
13+
/// </summary>
14+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
15+
public sealed class ParameterlessCtorRequiredAnalyzer : DiagnosticAnalyzer
16+
{
17+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
18+
ImmutableArray.Create(SurgewaveDiagnostics.SRWV004_ParameterlessCtorRequired);
19+
20+
public override void Initialize(AnalysisContext context)
21+
{
22+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
23+
context.EnableConcurrentExecution();
24+
context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
25+
}
26+
27+
private static void AnalyzeNamedType(SymbolAnalysisContext context)
28+
{
29+
var type = (INamedTypeSymbol)context.Symbol;
30+
if (type.TypeKind != TypeKind.Class || type.IsAbstract || type.IsStatic)
31+
return;
32+
33+
if (!PluginShouldBeSealedAnalyzer.ImplementsPluginInterface(type))
34+
return;
35+
36+
// Default behaviour: when no ctors are declared, the compiler synthesises
37+
// a public parameterless one — that path is fine, nothing to report.
38+
var declaredCtors = type.InstanceConstructors
39+
.Where(c => !c.IsImplicitlyDeclared)
40+
.ToList();
41+
if (declaredCtors.Count == 0) return;
42+
43+
var hasParameterless = declaredCtors.Any(c => c.Parameters.Length == 0);
44+
if (hasParameterless) return;
45+
46+
var diag = Diagnostic.Create(
47+
SurgewaveDiagnostics.SRWV004_ParameterlessCtorRequired,
48+
type.Locations[0],
49+
type.Name);
50+
context.ReportDiagnostic(diag);
51+
}
52+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.Diagnostics;
4+
5+
namespace Kuestenlogik.Surgewave.Analyzers;
6+
7+
/// <summary>
8+
/// SRWV001: types implementing an <c>IPlugin</c> interface (IBrokerPlugin,
9+
/// IProtocolPlugin, IStorageEnginePlugin) should be marked <c>sealed</c>.
10+
/// </summary>
11+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
12+
public sealed class PluginShouldBeSealedAnalyzer : DiagnosticAnalyzer
13+
{
14+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
15+
ImmutableArray.Create(SurgewaveDiagnostics.SRWV001_PluginShouldBeSealed);
16+
17+
public override void Initialize(AnalysisContext context)
18+
{
19+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
20+
context.EnableConcurrentExecution();
21+
context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
22+
}
23+
24+
private static void AnalyzeNamedType(SymbolAnalysisContext context)
25+
{
26+
var type = (INamedTypeSymbol)context.Symbol;
27+
if (type.TypeKind != TypeKind.Class || type.IsAbstract || type.IsStatic || type.IsSealed)
28+
return;
29+
30+
if (!ImplementsPluginInterface(type))
31+
return;
32+
33+
var diag = Diagnostic.Create(
34+
SurgewaveDiagnostics.SRWV001_PluginShouldBeSealed,
35+
type.Locations[0],
36+
type.Name);
37+
context.ReportDiagnostic(diag);
38+
}
39+
40+
/// <summary>
41+
/// True when the type implements at least one of the three plugin
42+
/// contracts. Matched by name to avoid taking a project reference to
43+
/// Surgewave.Plugins from the analyzer assembly.
44+
/// </summary>
45+
internal static bool ImplementsPluginInterface(INamedTypeSymbol type)
46+
{
47+
foreach (var iface in type.AllInterfaces)
48+
{
49+
var fullName = iface.ToDisplayString();
50+
if (fullName == "Kuestenlogik.Surgewave.Plugins.IBrokerPlugin"
51+
|| fullName == "Kuestenlogik.Surgewave.Plugins.IProtocolPlugin"
52+
|| fullName == "Kuestenlogik.Surgewave.Plugins.IStorageEnginePlugin"
53+
|| fullName == "Kuestenlogik.Surgewave.Plugins.IControlPlugin")
54+
{
55+
return true;
56+
}
57+
}
58+
return false;
59+
}
60+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace Kuestenlogik.Surgewave.Analyzers;
4+
5+
/// <summary>
6+
/// Central registry of the SRWV-prefix diagnostic descriptors. Each
7+
/// rule is registered here once, the per-rule analyser picks its
8+
/// descriptor by id. Keeps the descriptor strings out of every
9+
/// analyser class so a docs update is a single-file change.
10+
///
11+
/// SRWV-prefix is the Surgewave native-protocol magic-byte string —
12+
/// chosen so the analyser ids are recognisable as Surgewave-owned even
13+
/// without context.
14+
/// </summary>
15+
internal static class SurgewaveDiagnostics
16+
{
17+
private const string Category = "Surgewave.Plugins";
18+
private const string HelpLinkBase = "https://surgewave.kuestenlogik.com/docs/analyzers/";
19+
20+
public static readonly DiagnosticDescriptor SRWV001_PluginShouldBeSealed = new(
21+
id: "SRWV001",
22+
title: "Plugin class should be sealed",
23+
messageFormat: "Plugin class '{0}' should be sealed — plugin types are discovered by interface scan and never inherited from",
24+
category: Category,
25+
defaultSeverity: DiagnosticSeverity.Warning,
26+
isEnabledByDefault: true,
27+
description: "Surgewave's plugin loader instantiates the plugin type via parameterless ctor. Subclassing has no use-case and confuses the activator scan; mark the type sealed to make the intent explicit.",
28+
helpLinkUri: HelpLinkBase + "srwv001");
29+
30+
public static readonly DiagnosticDescriptor SRWV002_ConfigureMustNotBlock = new(
31+
id: "SRWV002",
32+
title: "Configure must not block",
33+
messageFormat: "Configure should be non-blocking — move I/O into ConfigureAsync",
34+
category: Category,
35+
defaultSeverity: DiagnosticSeverity.Warning,
36+
isEnabledByDefault: true,
37+
description: "(Stub) Reserved for follow-up: detect Wait()/Result/Thread.Sleep inside IBrokerPlugin.Configure or IProtocolPlugin.Configure.",
38+
helpLinkUri: HelpLinkBase + "srwv002");
39+
40+
public static readonly DiagnosticDescriptor SRWV003_ManifestClassMustResolve = new(
41+
id: "SRWV003",
42+
title: "plugin.json assemblies entry must resolve to a discoverable IPlugin",
43+
messageFormat: "Reserved for follow-up — rule is registered but inert in this release",
44+
category: Category,
45+
defaultSeverity: DiagnosticSeverity.Warning,
46+
isEnabledByDefault: false,
47+
description: "(Stub) Reserved for follow-up: cross-check plugin.json 'assemblies' against discovered IPlugin implementations.",
48+
helpLinkUri: HelpLinkBase + "srwv003");
49+
50+
public static readonly DiagnosticDescriptor SRWV004_ParameterlessCtorRequired = new(
51+
id: "SRWV004",
52+
title: "Plugin class must have a parameterless constructor",
53+
messageFormat: "Plugin class '{0}' must declare a parameterless constructor — the activator uses Activator.CreateInstance",
54+
category: Category,
55+
defaultSeverity: DiagnosticSeverity.Error,
56+
isEnabledByDefault: true,
57+
description: "BrokerPluginActivator instantiates plugin types via Activator.CreateInstance with no arguments. A type with only ctors that take parameters cannot be loaded.",
58+
helpLinkUri: HelpLinkBase + "srwv004");
59+
60+
public static readonly DiagnosticDescriptor SRWV005_PluginIdUniqueness = new(
61+
id: "SRWV005",
62+
title: "Plugin id must be unique across loaded plugins",
63+
messageFormat: "Reserved for follow-up — rule is registered but inert in this release",
64+
category: Category,
65+
defaultSeverity: DiagnosticSeverity.Warning,
66+
isEnabledByDefault: false,
67+
description: "(Stub) Reserved for follow-up: cross-project id collision detection.",
68+
helpLinkUri: HelpLinkBase + "srwv005");
69+
70+
public static readonly DiagnosticDescriptor SRWV006_XmlDocOnPublicApi = new(
71+
id: "SRWV006",
72+
title: "Plugin public API should carry XML documentation",
73+
messageFormat: "Reserved for follow-up — rule is registered but inert in this release",
74+
category: Category,
75+
defaultSeverity: DiagnosticSeverity.Info,
76+
isEnabledByDefault: false,
77+
description: "(Stub) Reserved for follow-up: enforce <summary> on public IPlugin members.",
78+
helpLinkUri: HelpLinkBase + "srwv006");
79+
80+
public static readonly DiagnosticDescriptor SRWV007_CancellationTokenOnAsyncLifecycle = new(
81+
id: "SRWV007",
82+
title: "Async lifecycle methods should accept CancellationToken",
83+
messageFormat: "Reserved for follow-up — rule is registered but inert in this release",
84+
category: Category,
85+
defaultSeverity: DiagnosticSeverity.Warning,
86+
isEnabledByDefault: false,
87+
description: "(Stub) Reserved for follow-up: warn when async IBrokerPlugin lifecycle members miss a CancellationToken param.",
88+
helpLinkUri: HelpLinkBase + "srwv007");
89+
90+
public static readonly DiagnosticDescriptor SRWV008_NoSurgewaveInternalsImport = new(
91+
id: "SRWV008",
92+
title: "Do not import Surgewave internal namespaces",
93+
messageFormat: "Reserved for follow-up — rule is registered but inert in this release",
94+
category: Category,
95+
defaultSeverity: DiagnosticSeverity.Warning,
96+
isEnabledByDefault: false,
97+
description: "(Stub) Reserved for follow-up: ban Kuestenlogik.Surgewave.*.Internal namespaces in plugin authoring projects.",
98+
helpLinkUri: HelpLinkBase + "srwv008");
99+
100+
public static readonly DiagnosticDescriptor SRWV009_ManifestVersionMatchesPackage = new(
101+
id: "SRWV009",
102+
title: "plugin.json version should match the project's package version",
103+
messageFormat: "Reserved for follow-up — rule is registered but inert in this release",
104+
category: Category,
105+
defaultSeverity: DiagnosticSeverity.Info,
106+
isEnabledByDefault: false,
107+
description: "(Stub) Reserved for follow-up: AdditionalFiles-based comparison between plugin.json version and the csproj PackageVersion.",
108+
helpLinkUri: HelpLinkBase + "srwv009");
109+
110+
public static readonly DiagnosticDescriptor SRWV010_LoggerOverConsole = new(
111+
id: "SRWV010",
112+
title: "Use ILogger instead of Console.WriteLine",
113+
messageFormat: "Console.Write/WriteLine in a Surgewave plugin bypasses the broker logging pipeline — inject ILogger<T> instead",
114+
category: Category,
115+
defaultSeverity: DiagnosticSeverity.Warning,
116+
isEnabledByDefault: true,
117+
description: "Surgewave's logging pipeline routes through Microsoft.Extensions.Logging. Console.WriteLine bypasses log levels, structured logging, JSON output and OTel correlation — replace with a ctor-injected ILogger<T>.",
118+
helpLinkUri: HelpLinkBase + "srwv010");
119+
120+
/// <summary>Flat list — handed to <see cref="DiagnosticAnalyzer.SupportedDiagnostics"/>.</summary>
121+
public static readonly ImmutableArrayBuilder<DiagnosticDescriptor> All = new(new[]
122+
{
123+
SRWV001_PluginShouldBeSealed,
124+
SRWV002_ConfigureMustNotBlock,
125+
SRWV003_ManifestClassMustResolve,
126+
SRWV004_ParameterlessCtorRequired,
127+
SRWV005_PluginIdUniqueness,
128+
SRWV006_XmlDocOnPublicApi,
129+
SRWV007_CancellationTokenOnAsyncLifecycle,
130+
SRWV008_NoSurgewaveInternalsImport,
131+
SRWV009_ManifestVersionMatchesPackage,
132+
SRWV010_LoggerOverConsole,
133+
});
134+
}
135+
136+
/// <summary>Tiny wrapper so the static field can hold an <c>ImmutableArray</c> without
137+
/// constructing it in a static initialiser that would race against descriptor construction.</summary>
138+
internal readonly struct ImmutableArrayBuilder<T>
139+
{
140+
public readonly System.Collections.Immutable.ImmutableArray<T> Items;
141+
public ImmutableArrayBuilder(T[] items) => Items = System.Collections.Immutable.ImmutableArray.Create(items);
142+
}

0 commit comments

Comments
 (0)