From 850a98f297aca6ef68315083f2c06288ac2aa11b Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 18:11:09 +0200 Subject: [PATCH] fix(generator): discovery walkers now process nested types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolicySymbolWalker and RequireSymbolWalker pushed nested types onto the walk-stack via type.GetTypeMembers() but only iterated the popped nested type's members — the nested type itself was never passed to ProcessType. Result: [Policy] / [RequirePolicy] on a nested class were silently ignored. Surfaced during v2.1 runtime test scaffolding (Mediator.Authorization PR #28). Workaround was to declare fixtures at file scope; this fix removes that constraint. Added regression tests covering both walkers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Discovery/PolicySymbolWalker.cs | 9 ++ .../Discovery/RequireSymbolWalker.cs | 9 ++ .../NestedTypeDiscoveryTests.cs | 92 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 tests/ZeroAlloc.Authorization.Generator.Tests/NestedTypeDiscoveryTests.cs diff --git a/src/ZeroAlloc.Authorization.Generator/Discovery/PolicySymbolWalker.cs b/src/ZeroAlloc.Authorization.Generator/Discovery/PolicySymbolWalker.cs index 0b9c8aa..4932664 100644 --- a/src/ZeroAlloc.Authorization.Generator/Discovery/PolicySymbolWalker.cs +++ b/src/ZeroAlloc.Authorization.Generator/Discovery/PolicySymbolWalker.cs @@ -46,6 +46,15 @@ private static void WalkNamespace( while (stack.Count > 0) { var current = stack.Pop(); + + // Nested types pushed via type.GetTypeMembers() are popped here as INamedTypeSymbol. + // The original walker only iterated their members; ProcessType was never called on the + // nested type itself. Without this, [Policy] on a nested class is silently ignored. + if (current is INamedTypeSymbol currentType) + { + ProcessType(currentType, policyAttr, policyInterfaces, sink, diagnostics); + } + foreach (var member in current.GetMembers()) { if (member is INamespaceSymbol ns) diff --git a/src/ZeroAlloc.Authorization.Generator/Discovery/RequireSymbolWalker.cs b/src/ZeroAlloc.Authorization.Generator/Discovery/RequireSymbolWalker.cs index bea4925..93a12cf 100644 --- a/src/ZeroAlloc.Authorization.Generator/Discovery/RequireSymbolWalker.cs +++ b/src/ZeroAlloc.Authorization.Generator/Discovery/RequireSymbolWalker.cs @@ -38,6 +38,15 @@ private static void WalkNamespace( while (stack.Count > 0) { var current = stack.Pop(); + + // Nested types pushed via type.GetTypeMembers() are popped here as INamedTypeSymbol. + // The original walker only iterated their members; ProcessType was never called on the + // nested type itself. Without this, [RequirePolicy] on a nested class is silently ignored. + if (current is INamedTypeSymbol currentType) + { + ProcessType(currentType, requireAttr, requireAnyAttr, sink, diagnostics); + } + foreach (var member in current.GetMembers()) { if (member is INamespaceSymbol ns) diff --git a/tests/ZeroAlloc.Authorization.Generator.Tests/NestedTypeDiscoveryTests.cs b/tests/ZeroAlloc.Authorization.Generator.Tests/NestedTypeDiscoveryTests.cs new file mode 100644 index 0000000..b246a2c --- /dev/null +++ b/tests/ZeroAlloc.Authorization.Generator.Tests/NestedTypeDiscoveryTests.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ZeroAlloc.Authorization.Generator.Tests; + +public sealed class NestedTypeDiscoveryTests +{ + [Fact] + public void Policy_OnNestedClass_IsDiscovered() + { + var source = @" +using System.Threading; +using System.Threading.Tasks; +using ZeroAlloc.Authorization; +using ZeroAlloc.Results; +namespace MyApp; +public static class Outer +{ + [Policy(""NestedPolicy"")] + public sealed class NestedPolicy : IAuthorizationPolicy + { + public ValueTask> EvaluateAsync(ISecurityContext c, CancellationToken t = default) + => new(UnitResult.Success()); + } +} +"; + var diags = RunGenerator(source); + // Without the fix, the nested policy would be silently ignored — no diagnostics would fire. + // With the fix, ZAUTH002 (duplicate name) does NOT fire (only one declaration) and ZAUTH003 + // (doesn't implement interface) does NOT fire — the policy is correctly recognised. + // The simplest assertion: no error diagnostics from this source. + Assert.DoesNotContain(diags, d => d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void RequirePolicy_OnNestedRecord_IsDiscovered() + { + var source = @" +using System.Threading; +using System.Threading.Tasks; +using ZeroAlloc.Authorization; +using ZeroAlloc.Results; +namespace MyApp; +[Policy(""Admin"")] +public sealed class AdminPolicy : IAuthorizationPolicy +{ + public ValueTask> EvaluateAsync(ISecurityContext c, CancellationToken t = default) + => new(UnitResult.Success()); +} +public static class Outer +{ + [RequirePolicy(""Admin"")] + public sealed record NestedCmd(); +} +"; + var diags = RunGenerator(source); + // Without the fix, the nested [RequirePolicy] is silently ignored — no AuthorizerFor is emitted. + // With the fix, ZAUTH001 (unknown policy name) does NOT fire because the [Policy] is found + // and matched against the [RequirePolicy("Admin")] on NestedCmd. + Assert.DoesNotContain(diags, d => string.Equals(d.Id, "ZAUTH001", StringComparison.Ordinal)); + } + + private static ImmutableArray RunGenerator(string source) + { + var refs = GetStandardReferences(); + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { CSharpSyntaxTree.ParseText(source) }, + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var generator = new PolicyRegistryGenerator().AsSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(generator).RunGenerators(compilation); + return driver.GetRunResult().Diagnostics; + } + + private static List GetStandardReferences() + { + _ = typeof(ZeroAlloc.Authorization.PolicyAttribute).FullName; + _ = typeof(ZeroAlloc.Results.UnitResult<>).FullName; + + var references = new List(); + foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.IsDynamic) continue; + if (string.IsNullOrEmpty(asm.Location)) continue; + references.Add(MetadataReference.CreateFromFile(asm.Location)); + } + return references; + } +}