AOP Analyzers — Compile-Time Diagnostics
When you install ZibStack.NET.Aop you also get a set of Roslyn analyzers and code fixes that catch broken aspect placements and enforce architectural rules at compile time. The diagnostics show up directly in your IDE — red squiggle, light-bulb fix, no waiting for a build. No separate package install required.
Why this matters: the source-generator part can only emit interceptors for placements that are physically possible. Anything else (aspect on a
staticmethod,[Cache]on avoidmethod,[Retry(MaxAttempts = 0)]) silently no-ops at runtime and you’d never know. Analyzers turn those silent failures into immediate, locatable errors.
Diagnostic Categories
Section titled “Diagnostic Categories”Four families, all under the ZibStack.Aop category:
| Family | Covers | Severity |
|---|---|---|
Tier 1 — Placement (AOP0001–AOP0006) | The mechanics of C# interceptors: where an aspect can be placed and what kind of method it can wrap. | Mostly Error |
Tier 2 — Attribute Arguments (AOP0010–AOP0017, AOP0030–AOP0041) | Per-aspect semantic checks of the values you pass. Covers core aspects ([Cache], [Retry], [Timeout], [Validate]) AND optional add-on packages ([PollyRetry], [HttpRetry], [PollyCircuitBreaker], [PollyRateLimiter], [HybridCache]). The optional checks fire only when those packages are referenced — silent otherwise. | Error / Warning / Info |
Tier 3 — Call Sites (AOP0020–AOP0021) | Code patterns that look like they would invoke the aspect but actually bypass the interceptor — or, in the case of base.Method() over an aspect-decorated virtual, recurse infinitely. | Warning / Error |
Tier 4 — Convention Enforcement (AOP1001–AOP1005) | Architectural rules you declare on a base type / scoped type, enforced on derivatives or on the call site — required aspects, interfaces, methods, constructors, and namespace-scoped usage. | Warning |
Tier 1 — Placement
Section titled “Tier 1 — Placement”These fire on the method (or attribute) that the aspect is applied to.
AOP0001 — Aspect on static method (Error)
Section titled “AOP0001 — Aspect on static method (Error)”C# interceptors require an instance receiver (this @this). Static methods cannot be intercepted.
public class Svc{ [Log] public static void DoWork() { } // ❌ AOP0001}Code fix: Remove the aspect attribute.
AOP0002 — Aspect on private/protected method (Error)
Section titled “AOP0002 — Aspect on private/protected method (Error)”The generated interceptor lives in a separate __X_Aop class and is not a member of the target type nor a derived class. C# access rules forbid it from invoking private/protected/private protected methods.
public class Svc{ [Log] private void DoWork() { } // ❌ AOP0002}Code fix: Make the method internal (lowest accessibility the interceptor can reach).
Class-level aspects automatically pick up
public,internal, andprotected internalinstance methods of the type — no warning fires for those, since the parser simply skips members it can’t intercept.
AOP0003 — Aspect on method with ref/out/in parameter (Error)
Section titled “AOP0003 — Aspect on method with ref/out/in parameter (Error)”The interceptor stores parameter values in an AspectParameterInfo[] for AspectContext.Parameters. ref/out/in cannot survive that capture.
public class Svc{ [Log] public void Pull(out int x) { x = 1; } // ❌ AOP0003}AOP0003B — Aspect on method returning by ref (Error)
Section titled “AOP0003B — Aspect on method returning by ref (Error)”private int _x;
[Log]public ref int GetRef() => ref _x; // ❌ AOP0003BAOP0004 — [AspectHandler] type does not implement a handler interface (Error)
Section titled “AOP0004 — [AspectHandler] type does not implement a handler interface (Error)”Reported on the [AspectHandler(typeof(...))] declaration itself.
[AspectHandler(typeof(NotAHandler))] // ❌ AOP0004public sealed class BrokenAspectAttribute : AspectAttribute { }
public class NotAHandler { } // doesn't implement any I*HandlerAOP0005 — Custom AspectAttribute missing [AspectHandler] (Error)
Section titled “AOP0005 — Custom AspectAttribute missing [AspectHandler] (Error)”Without an [AspectHandler], the generator has nothing to wire up.
[AttributeUsage(AttributeTargets.Method)]public sealed class OrphanedAspectAttribute : AspectAttribute { } // ❌ AOP0005AOP0006 — Aspect on operator or conversion (Error)
Section titled “AOP0006 — Aspect on operator or conversion (Error)”The generator only intercepts ordinary instance methods.
public class Box{ [Log] public static Box operator +(Box a, Box b) => new(); // ❌ AOP0006}Tier 2 — Attribute Arguments
Section titled “Tier 2 — Attribute Arguments”Reported on the attribute application itself, so the squiggle lands precisely on the wrong value.
[Cache]
Section titled “[Cache]”| ID | Severity | Trigger | Code fix |
|---|---|---|---|
AOP0010 | Warning | [Cache] on void / non-generic Task — silently suppresses subsequent calls (including side effects in the body) after the first | Remove [Cache] |
[Cache] // ❌ AOP0010public void DoWork() { }
[Cache] // ❌ AOP0010public Task DoAsync() => Task.CompletedTask;
[Cache] // ✅ ok — Task<int> has a valuepublic Task<int> GetAsync() => Task.FromResult(1);[Retry]
Section titled “[Retry]”| ID | Severity | Trigger | Code fix |
|---|---|---|---|
AOP0011 | Error | MaxAttempts <= 0 — would never even execute | Set MaxAttempts = 3 |
AOP0012 | Error | DelayMs < 0 | Set DelayMs = 0 |
AOP0013 | Warning | BackoffMultiplier < 1.0 — shrinks delay between retries | Set BackoffMultiplier = 1.0 |
[Retry(MaxAttempts = 0)] // ❌ AOP0011public int A() => 1;
[Retry(DelayMs = -100)] // ❌ AOP0012public int B() => 1;
[Retry(BackoffMultiplier = 0.5)] // ⚠ AOP0013 — each retry waits less than the lastpublic int C() => 1;[Timeout]
Section titled “[Timeout]”| ID | Severity | Trigger | Code fix |
|---|---|---|---|
AOP0014 | Error | TimeoutMs <= 0 | Set TimeoutMs = 30000 |
AOP0015 | Warning | Method has no CancellationToken parameter — handler can’t signal cooperative cancellation, body leaks on timeout | Add CancellationToken cancellationToken = default parameter |
[Timeout(TimeoutMs = 0)] // ❌ AOP0014public Task<int> A(CancellationToken ct) => Task.FromResult(1);
[Timeout(TimeoutMs = 5000)] // ⚠ AOP0015 — body has no CT to observe → leaks on timeoutpublic Task<int> B() => Task.FromResult(1);
[Timeout(TimeoutMs = 5000)] // ✅ ok — body's Task.Delay observes ct and cooperatively abortspublic async Task<int> C(CancellationToken ct = default){ await Task.Delay(1000, ct); return 42;}
[Timeout]semantics — cooperative when CT param present: if the method declares aCancellationTokenparameter, the generator threads a linkedCancellationTokenSourcethrough it andTimeoutHandlercallsCancelAfter(TimeoutMs). The body’s awaits (e.g.Task.Delay(ms, ct),HttpClient.GetAsync(url, ct)) observe the cancellation and abort cooperatively — no background leak. Without the CT param the handler falls back toTask.WhenAny: the caller still seesTimeoutExceptionpromptly, but the body has no signal channel and runs to completion in the background. AOP0015 warns about exactly that case.
[PollyRetry] / [HttpRetry] (optional package: ZibStack.NET.Aop.Polly)
Section titled “[PollyRetry] / [HttpRetry] (optional package: ZibStack.NET.Aop.Polly)”| ID | Severity | Trigger |
|---|---|---|
AOP0030 | Error | [PollyRetry(MaxRetryAttempts <= 0)] |
AOP0031 | Error | [PollyRetry(DelayMs < 0)] |
AOP0032 | Error | [HttpRetry(MaxRetryAttempts <= 0)] |
AOP0033 | Error | [HttpRetry(DelayMs < 0)] |
[PollyCircuitBreaker] (optional)
Section titled “[PollyCircuitBreaker] (optional)”| ID | Severity | Trigger |
|---|---|---|
AOP0034 | Error | FailureThreshold not in (0, 1] (it’s a probability — open the breaker when X percent of calls fail) |
AOP0035 | Error | MinimumThroughput < 1 |
AOP0036 | Error | SamplingDurationSeconds <= 0 |
AOP0037 | Error | BreakDurationSeconds <= 0 |
[PollyRateLimiter] (optional)
Section titled “[PollyRateLimiter] (optional)”| ID | Severity | Trigger |
|---|---|---|
AOP0038 | Error | PermitLimit <= 0 |
AOP0039 | Error | WindowSeconds <= 0 |
AOP0040 | Error | QueueLimit < 0 (use 0 to reject overflow immediately) |
[HybridCache] (optional package: ZibStack.NET.Aop.HybridCache)
Section titled “[HybridCache] (optional package: ZibStack.NET.Aop.HybridCache)”| ID | Severity | Trigger |
|---|---|---|
AOP0041 | Error | DurationSeconds < 0 (use 0 for unlimited TTL) |
No Polly/HybridCache package needed for these analyzers. They live in the main
ZibStack.NET.Aopanalyzer DLL and identify their target attributes by full name. If you don’t reference Polly or HybridCache, the corresponding checks just don’t resolve any matches — zero false positives, zero overhead.
[Validate]
Section titled “[Validate]”| ID | Severity | Trigger | Code fix |
|---|---|---|---|
AOP0016 | Warning | Method has no parameters | Remove [Validate] |
AOP0017 | Info | None of the parameters or their reachable property graph carry DataAnnotations | (none — diagnostic only) |
[Validate] // ❌ AOP0016 — nothing to validatepublic int Get() => 1;
[Validate] // ℹ AOP0017 — no [Required]/[Range]/...public int Sum(int a, int b) => a + b;
[Validate] // ✅ ok — Order has [Range] on a propertypublic void Place(Order order) { }
public class Order{ [Range(1, 100)] public int Quantity { get; set; }}Tier 3 — Call Sites
Section titled “Tier 3 — Call Sites”Diagnoses places where the call would go through the interceptor at first glance, but doesn’t.
AOP0020 — Method group → delegate (Warning)
Section titled “AOP0020 — Method group → delegate (Warning)”Converting an aspect-decorated method to Func<>/Action<>/event handler captures the original method directly. Calls through the delegate skip the interceptor entirely.
public class Svc{ [Log] public int GetOrder(int id) => id;}
public class Caller{ public Func<int, int> MakeFunc(Svc s) => s.GetOrder; // ⚠ AOP0020 // ^^^^^^^^^^ // The Func captures the unwrapped method. // Calling it later won't trigger [Log].}AOP0021 — base.Method() to an aspect-decorated virtual method causes infinite recursion (Error)
Section titled “AOP0021 — base.Method() to an aspect-decorated virtual method causes infinite recursion (Error)”This was originally documented as “bypasses the aspect on the override”. A behavioral
test proved the OPPOSITE: the call IS intercepted, and because the interceptor body
invokes the target via @this.Method(...) (virtual dispatch) it lands back in the
override, which calls base.Method() again — guaranteed StackOverflowException at
runtime. The diagnostic was bumped to Error and the message rewritten.
public class Base{ [Log] public virtual int GetOrder(int id) => id;}
public class Derived : Base{ public override int GetOrder(int id) => base.GetOrder(id); // ❌ AOP0021 — guaranteed SOE}Either remove the override, remove the aspect, or reshape the call to avoid base..
Tier 4 — Convention Enforcement
Section titled “Tier 4 — Convention Enforcement”Declarative architecture rules. You annotate a base class or interface with
[RequireAspect(typeof(X))]; the analyzer warns on every concrete derivative that doesn’t
also carry [X]. Same idea as Metalama’s architecture validation — but as one focused
attribute, no fabrics, no compile-time API.
AOP1001 — Type / method missing aspect required by base or interface (Warning)
Section titled “AOP1001 — Type / method missing aspect required by base or interface (Warning)”The [RequireAspect] attribute is placement-based — where you put it determines
what must satisfy the rule:
On a class or interface — every concrete derivative needs the aspect
Section titled “On a class or interface — every concrete derivative needs the aspect”[RequireAspect(typeof(LogAttribute), Reason = "All Topics must be audited")]public abstract class Topic { }
public class OrderPlaced : Topic { }// ^^^^^^^^^^^^// ⚠ AOP1001: 'OrderPlaced' derives from 'Topic' which requires [Log].// Reason: All Topics must be audited.
[Log]public class PaymentMade : Topic { } // ✅ okSame for interfaces:
[RequireAspect(typeof(TraceAttribute), Reason = "All command handlers must be traceable")]public interface ICommandHandler { }
public class CreateOrderHandler : ICommandHandler { }// ^^^^^^^^^^^^^^^^^^// ⚠ AOP1001: requires [Trace]On a method — every override / interface implementation needs the aspect
Section titled “On a method — every override / interface implementation needs the aspect”public interface ICommandHandler{ [RequireAspect(typeof(TraceAttribute))] Task HandleAsync(object cmd);}
public class CreateOrderHandler : ICommandHandler{ public Task HandleAsync(object cmd) => ...; // ^^^^^^^^^^^ // ⚠ AOP1001: 'HandleAsync' implements 'ICommandHandler.HandleAsync' which requires [Trace]}
public class CancelOrderHandler : ICommandHandler{ [Trace] public Task HandleAsync(object cmd) => ...; // ✅}Class-level aspect satisfies a method-level requirement
Section titled “Class-level aspect satisfies a method-level requirement”This avoids false positives when the developer uses the class-level shortcut: putting
[Trace] on the impl class propagates to every public/internal method, so a
method-level [RequireAspect(typeof(TraceAttribute))] is satisfied automatically.
[Trace] // class-level → propagates to HandleAsyncpublic class RefundOrderHandler : ICommandHandler{ public Task HandleAsync(object cmd) => ...; // ✅ satisfied via class-level [Trace]}Code fix: “Add [Aspect]” — inserts the attribute on its own line above the declaration with matching indentation.
Notes:
- Abstract intermediate classes / abstract methods are exempt; only concrete usage sites are flagged.
- Multiple
[RequireAspect]attributes on a single base produce one diagnostic per missing aspect — fix them one at a time with the light-bulb. - The same requirement reachable via two paths (e.g. base class and interface) is collapsed to one diagnostic.
- Subclasses of the required aspect are accepted (e.g.
[VerboseLog : LogAttribute]satisfies a[RequireAspect(typeof(LogAttribute))]). - Suppress per-type with
#pragma warning disable AOP1001if a particular derivative is legitimately exempt.
AOP1002 — Type missing interface required by base/interface (Warning)
Section titled “AOP1002 — Type missing interface required by base/interface (Warning)”[RequireImplementation(typeof(IDisposable), Reason = "Connections must clean up sockets")]public abstract class DatabaseConnection { }
public class SqlConnection : DatabaseConnection { }// ^^^^^^^^^^^^^// ⚠ AOP1002: 'SqlConnection' derives from 'DatabaseConnection' which requires// implementing 'IDisposable'. Reason: Connections must clean up sockets.
public class PgConnection : DatabaseConnection, IDisposable{ public void Dispose() { }} // ✅ okUse this for cross-cutting capabilities (IDisposable, IAsyncDisposable, custom marker
interfaces) that the base type cannot inherit directly without forcing every member onto
the contract.
Code fix: “Implement {Interface}” — appends the interface to the base list. The compiler’s own CS0535 light-bulb (“Implement interface”) then takes over to stub the required members.
AOP1003 — Type missing method required by base/interface (Warning)
Section titled “AOP1003 — Type missing method required by base/interface (Warning)”For plug-in / module conventions where the framework calls a method by name (often via reflection) and the signature varies per host, so it can’t be encoded in an abstract member.
[RequireMethod("Configure", ReturnType = typeof(void), Parameters = new[] { typeof(IServiceCollection) }, Reason = "Modules must register their services")]public abstract class Module { }
public class AuthModule : Module { }// ^^^^^^^^^^// ⚠ AOP1003: requires 'void Configure(IServiceCollection)'. Reason: Modules must register their services.
public class OrderModule : Module{ public void Configure(IServiceCollection services) { } // ✅}ReturnType and Parameters are optional — when omitted, only the method name is
checked. Methods inherited from intermediate base classes satisfy the rule, so a base
class can ship a virtual default implementation without tripping the analyzer.
No code fix — generating method stubs is too opinionated about body shape; the analyzer just points you at the missing method.
AOP1004 — Type missing constructor required by base/interface (Warning)
Section titled “AOP1004 — Type missing constructor required by base/interface (Warning)”For DI-activated bases where the framework new’s-up the derivative with specific dependencies. The runtime would otherwise fail with “no matching constructor” during DI resolution.
[RequireConstructor(typeof(IServiceProvider), Reason = "Plugins are activated by the host with the request scope")]public abstract class Plugin { }
public class GoodPlugin : Plugin{ public GoodPlugin(IServiceProvider sp) { } // ✅}
public class BrokenPlugin : Plugin{ public BrokenPlugin() { }// ^^^^^^^^^^^^// ⚠ AOP1004: 'BrokenPlugin' derives from 'Plugin' which requires a public// constructor '(System.IServiceProvider)'.}Pass an empty parameter list to require a parameterless ctor:
[RequireConstructor]. Apply multiple times for alternative shapes — each missing
shape is reported separately. Public instance constructors only count as
satisfying the rule (private/protected ctors don’t help an external activator).
Code fix: “Add constructor (…)” — inserts a stub public TypeName(p0, p1, ...)
at the top of the class body with throw new NotImplementedException() placeholder.
AOP1005 — Type used outside its allowed scope (Warning)
Section titled “AOP1005 — Type used outside its allowed scope (Warning)”The “convention enforcement” counterpart of [RequireAspect]: instead of “every
derivative must do X”, it says “only callers in scope X may touch this type at
all”. Use it for internal-but-public engine helpers, test-only constructors, or
to carve out a private API surface inside a single library.
namespace MyApp.Internal{ [ScopeTo("MyApp.Internal.**", Reason = "Engine bypass — public API is in MyApp.Public")] public class SecretEngine { public void DoMagic() { } }}
namespace MyApp.Internal.Things{ var e = new SecretEngine(); // ✅ allowed under MyApp.Internal.**}
namespace MyApp.Public.Api{ var e = new SecretEngine(); // ⚠ AOP1005 — call-site namespace 'MyApp.Public.Api' // not in allowed scope 'MyApp.Internal.**'}Pattern syntax:
"MyApp.Internal"— exact namespace match only (sub-namespaces don’t satisfy)"MyApp.Internal.**"— that namespace plus any sub-namespace
Apply multiple times to allow several scopes. Self-references inside the scoped
type itself are always allowed (the type can call its own static members regardless
of scope). Detection covers new T() (object creation) and T.Member(...) /
instance.Member(...) (method invocations).
No code fix — the right repair depends entirely on user intent (move the call, broaden the scope, or split the type).
Code Fix Summary
Section titled “Code Fix Summary”Twenty-five of the diagnostics ship a Roslyn code fix you can apply with Alt+Enter / Cmd+. :
| Diagnostic | Code fix |
|---|---|
AOP0001 | Remove aspect attribute from static method |
AOP0002 | Change accessibility to internal |
AOP0010 | Remove [Cache] from non-returning method |
AOP0011 | Set MaxAttempts = 3 |
AOP0012 | Set DelayMs = 0 |
AOP0013 | Set BackoffMultiplier = 1.0 |
AOP0014 | Set TimeoutMs = 30000 |
AOP0015 | Add CancellationToken cancellationToken = default parameter (then forward it to your awaits) |
AOP0016 | Remove [Validate] from parameterless method |
AOP1001 | Add [Aspect] attribute |
AOP1002 | Implement {Interface} (append to base list) |
AOP1004 | Add stub constructor matching the required signature (body: throw new NotImplementedException()) |
AOP0030–AOP0041 | Set the offending property to its package-documented default (e.g. MaxRetryAttempts → 3, FailureThreshold → 0.5, DurationSeconds → 300) |
The remaining diagnostics are intentionally fix-less — repairing them either requires an API redesign (AOP0003/AOP0006), depends on user intent (AOP0017), or describes legitimate code that should just be reviewed (AOP0020/AOP0021).
Suppression
Section titled “Suppression”Standard Roslyn suppression works:
#pragma warning disable AOP0017[Validate]public int Sum(int a, int b) => a + b; // intentional: validates nothing today, but the // contract is "can be called with anything"#pragma warning restore AOP0017Or in .editorconfig:
# Globally downgrade AOP0017 (Validate without DataAnnotations) to silent.dotnet_diagnostic.AOP0017.severity = noneWhy analyzers, not just runtime checks?
Section titled “Why analyzers, not just runtime checks?”Three reasons:
- Speed of feedback. The runtime would discover
[Cache]on avoidmethod only when that method was first called — and even then, the result is just “cache silently doesn’t help”. Analyzers fail in the editor, in seconds. - Locatable errors. A runtime exception from inside a generated interceptor points at generated code; an analyzer diagnostic points at the exact attribute or call site.
- No false friends. Many of these mistakes (negative
DelayMs,MaxAttempts = 0, method withoutCancellationToken) don’t crash — they just don’t do what you wanted. Analyzers refuse the bug instead of letting it run.