Skip to content

AOP Analyzers — Compile-Time Diagnostics

NuGet Source

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 static method, [Cache] on a void method, [Retry(MaxAttempts = 0)]) silently no-ops at runtime and you’d never know. Analyzers turn those silent failures into immediate, locatable errors.

Four families, all under the ZibStack.Aop category:

FamilyCoversSeverity
Tier 1 — Placement (AOP0001AOP0006)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 (AOP0010AOP0017, AOP0030AOP0041)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 (AOP0020AOP0021)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 (AOP1001AOP1005)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

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, and protected internal instance 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; // ❌ AOP0003B

AOP0004[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))] // ❌ AOP0004
public sealed class BrokenAspectAttribute : AspectAttribute { }
public class NotAHandler { } // doesn't implement any I*Handler

AOP0005 — 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 { } // ❌ AOP0005

AOP0006 — 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
}

Reported on the attribute application itself, so the squiggle lands precisely on the wrong value.

IDSeverityTriggerCode fix
AOP0010Warning[Cache] on void / non-generic Task — silently suppresses subsequent calls (including side effects in the body) after the firstRemove [Cache]
[Cache] // ❌ AOP0010
public void DoWork() { }
[Cache] // ❌ AOP0010
public Task DoAsync() => Task.CompletedTask;
[Cache] // ✅ ok — Task<int> has a value
public Task<int> GetAsync() => Task.FromResult(1);
IDSeverityTriggerCode fix
AOP0011ErrorMaxAttempts <= 0 — would never even executeSet MaxAttempts = 3
AOP0012ErrorDelayMs < 0Set DelayMs = 0
AOP0013WarningBackoffMultiplier < 1.0 — shrinks delay between retriesSet BackoffMultiplier = 1.0
[Retry(MaxAttempts = 0)] // ❌ AOP0011
public int A() => 1;
[Retry(DelayMs = -100)] // ❌ AOP0012
public int B() => 1;
[Retry(BackoffMultiplier = 0.5)] // ⚠ AOP0013 — each retry waits less than the last
public int C() => 1;
IDSeverityTriggerCode fix
AOP0014ErrorTimeoutMs <= 0Set TimeoutMs = 30000
AOP0015WarningMethod has no CancellationToken parameter — handler can’t signal cooperative cancellation, body leaks on timeoutAdd CancellationToken cancellationToken = default parameter
[Timeout(TimeoutMs = 0)] // ❌ AOP0014
public Task<int> A(CancellationToken ct) => Task.FromResult(1);
[Timeout(TimeoutMs = 5000)] // ⚠ AOP0015 — body has no CT to observe → leaks on timeout
public Task<int> B() => Task.FromResult(1);
[Timeout(TimeoutMs = 5000)] // ✅ ok — body's Task.Delay observes ct and cooperatively aborts
public 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 a CancellationToken parameter, the generator threads a linked CancellationTokenSource through it and TimeoutHandler calls CancelAfter(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 to Task.WhenAny: the caller still sees TimeoutException promptly, 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)”
IDSeverityTrigger
AOP0030Error[PollyRetry(MaxRetryAttempts <= 0)]
AOP0031Error[PollyRetry(DelayMs < 0)]
AOP0032Error[HttpRetry(MaxRetryAttempts <= 0)]
AOP0033Error[HttpRetry(DelayMs < 0)]
IDSeverityTrigger
AOP0034ErrorFailureThreshold not in (0, 1] (it’s a probability — open the breaker when X percent of calls fail)
AOP0035ErrorMinimumThroughput < 1
AOP0036ErrorSamplingDurationSeconds <= 0
AOP0037ErrorBreakDurationSeconds <= 0
IDSeverityTrigger
AOP0038ErrorPermitLimit <= 0
AOP0039ErrorWindowSeconds <= 0
AOP0040ErrorQueueLimit < 0 (use 0 to reject overflow immediately)

[HybridCache] (optional package: ZibStack.NET.Aop.HybridCache)

Section titled “[HybridCache] (optional package: ZibStack.NET.Aop.HybridCache)”
IDSeverityTrigger
AOP0041ErrorDurationSeconds < 0 (use 0 for unlimited TTL)

No Polly/HybridCache package needed for these analyzers. They live in the main ZibStack.NET.Aop analyzer 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.

IDSeverityTriggerCode fix
AOP0016WarningMethod has no parametersRemove [Validate]
AOP0017InfoNone of the parameters or their reachable property graph carry DataAnnotations(none — diagnostic only)
[Validate] // ❌ AOP0016 — nothing to validate
public int Get() => 1;
[Validate] // ℹ AOP0017 — no [Required]/[Range]/...
public int Sum(int a, int b) => a + b;
[Validate] // ✅ ok — Order has [Range] on a property
public void Place(Order order) { }
public class Order
{
[Range(1, 100)]
public int Quantity { get; set; }
}

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].
}

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

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 { } // ✅ ok

Same 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 HandleAsync
public 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 AOP1001 if 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() { }
} // ✅ ok

Use 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).

Twenty-five of the diagnostics ship a Roslyn code fix you can apply with Alt+Enter / Cmd+. :

DiagnosticCode fix
AOP0001Remove aspect attribute from static method
AOP0002Change accessibility to internal
AOP0010Remove [Cache] from non-returning method
AOP0011Set MaxAttempts = 3
AOP0012Set DelayMs = 0
AOP0013Set BackoffMultiplier = 1.0
AOP0014Set TimeoutMs = 30000
AOP0015Add CancellationToken cancellationToken = default parameter (then forward it to your awaits)
AOP0016Remove [Validate] from parameterless method
AOP1001Add [Aspect] attribute
AOP1002Implement {Interface} (append to base list)
AOP1004Add stub constructor matching the required signature (body: throw new NotImplementedException())
AOP0030AOP0041Set 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).

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 AOP0017

Or in .editorconfig:

# Globally downgrade AOP0017 (Validate without DataAnnotations) to silent.
dotnet_diagnostic.AOP0017.severity = none

Three reasons:

  1. Speed of feedback. The runtime would discover [Cache] on a void method 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.
  2. 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.
  3. No false friends. Many of these mistakes (negative DelayMs, MaxAttempts = 0, method without CancellationToken) don’t crash — they just don’t do what you wanted. Analyzers refuse the bug instead of letting it run.