From 2793cfb3b89d3fd227d3430c31653890919a08f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 16:16:39 +0200 Subject: [PATCH 1/5] fix: skip constructors with inaccessible parameter types A constructor parameter type is named verbatim in `MockExtensionsForXXX`, which does not derive from the mocked type. When the type is only reachable through inheritance (a `protected` nested type, or a `protected internal` one across assemblies), the generated code failed to compile with CS0122 there and CS0051 on the generated `public` constructor. Such a constructor cannot be driven from the outside at all, so it is now dropped entirely: classes with another accessible constructor still generate a mock, and classes left without one fall into the existing `IsValidMockDeclaration` gate. The conservative accessibility walk that already guarded emitted attribute names is promoted to a shared `Helpers.IsAccessibleFrom`, extended to recurse through array element types and generic type arguments. --- .../Entities/MockClass.cs | 6 + Source/Mockolate.SourceGenerators/Helpers.cs | 85 +++++++----- .../MockGeneratorTests.cs | 128 ++++++++++++++++++ .../MockTests.CrossAssemblyTests.cs | 59 ++++++++ 4 files changed, 241 insertions(+), 37 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Entities/MockClass.cs b/Source/Mockolate.SourceGenerators/Entities/MockClass.cs index 77df75ff..d55cce84 100644 --- a/Source/Mockolate.SourceGenerators/Entities/MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Entities/MockClass.cs @@ -24,6 +24,12 @@ public MockClass(ITypeSymbol[] types, IAssemblySymbol sourceAssembly) : base(typ .Where(x => x.DeclaredAccessibility == Accessibility.Protected || x.DeclaredAccessibility == Accessibility.ProtectedOrInternal || x.DeclaredAccessibility == Accessibility.Public) + // A constructor parameter type is named verbatim in `MockExtensionsForXXX`, which does + // not derive from the mocked type: a type that is only reachable through inheritance + // (a `protected` nested type, or a `protected internal` one across assemblies) would + // cause CS0122 there and CS0051 on the generated `public` constructor. Such a + // constructor cannot be driven from the outside at all, so drop it entirely. + .Where(x => x.Parameters.All(p => Helpers.IsAccessibleFrom(p.Type, sourceAssembly))) .Select(x => new Method(x, null, sourceAssembly)).ToArray()); if (namedTypeSymbol.DelegateInvokeMethod is not null) { diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 3bfff6ce..6e3780a2 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -121,6 +121,50 @@ public static string ResolveOverrideVisibility(Accessibility accessibility, _ => "private", }; + /// + /// Conservative visibility test for a type that the generator names verbatim in emitted code + /// (attribute names, constructor parameter types). A type is accessible only if its whole + /// containing chain, and every type it is composed of (array element, type argument), is + /// either Public, or Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the same + /// assembly). Private/Protected/ProtectedAndInternal nested types, and ProtectedOrInternal + /// across assemblies without IVT, are treated as inaccessible: the protected half is + /// only reachable by deriving from the declaring type, which the surfaces naming the type + /// (e.g. MockExtensionsForXXX) do not do. + /// + public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAssembly) + { + switch (type) + { + case IArrayTypeSymbol array: + return IsAccessibleFrom(array.ElementType, sourceAssembly); + case IPointerTypeSymbol pointer: + return IsAccessibleFrom(pointer.PointedAtType, sourceAssembly); + case INamedTypeSymbol named: + for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) + { + switch (t.DeclaredAccessibility) + { + case Accessibility.Public: + continue; + case Accessibility.Internal: + case Accessibility.ProtectedOrInternal: + if (sourceAssembly is null || HasInternalAccess(t.ContainingAssembly, sourceAssembly)) + { + continue; + } + + return false; + default: + return false; + } + } + + return named.TypeArguments.All(argument => IsAccessibleFrom(argument, sourceAssembly)); + default: + return true; + } + } + private static bool HasInternalAccess(IAssemblySymbol? containingAssembly, IAssemblySymbol? sourceAssembly) { if (sourceAssembly is null || containingAssembly is null) @@ -263,6 +307,10 @@ public bool HasReservedName(string candidate) { public EquatableArray? ToAttributeArray(IAssemblySymbol? sourceAssembly = null) { + // The attribute name is emitted verbatim into the generated code (e.g. + // `[global::Azure.Core.CallerShouldAudit(...)]`), so an attribute class that is not visible + // to the generated mock assembly would cause CS0122. Drop it instead of producing + // uncompilable output. Attribute[] consideredAttributes = attributes .Where(x => x.AttributeClass is not null && !IsCompilerEmittedAttribute(x.AttributeClass) @@ -292,43 +340,6 @@ static bool IsCompilerEmittedAttribute(INamedTypeSymbol attribute) or "AsyncStateMachineAttribute" or "IteratorStateMachineAttribute" or "AsyncIteratorStateMachineAttribute"; } - - // The attribute name is emitted verbatim into the generated code (e.g. - // `[global::Azure.Core.CallerShouldAudit(...)]`). If the attribute class — or any of its - // containing types — is not visible to the generated mock assembly, referencing it causes - // CS0122. Drop the attribute instead of producing uncompilable output. - // - // Conservative rule: a type is accessible only if its whole containing chain is either - // Public, or Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the - // same assembly). Private/Protected/ProtectedAndInternal nested types and - // ProtectedOrInternal across assemblies without IVT are treated as inaccessible — the - // `protected` half would require knowing the derivation relationship to the declaring - // type, which we don't verify here. - static bool IsAccessibleFrom(INamedTypeSymbol attribute, IAssemblySymbol? sourceAssembly) - { - for (INamedTypeSymbol? t = attribute; t is not null; t = t.ContainingType) - { - switch (t.DeclaredAccessibility) - { - case Accessibility.Public: - continue; - case Accessibility.Internal: - case Accessibility.ProtectedOrInternal: - if (sourceAssembly is null || - SymbolEqualityComparer.Default.Equals(t.ContainingAssembly, sourceAssembly) || - t.ContainingAssembly.GivesAccessTo(sourceAssembly)) - { - continue; - } - - return false; - default: - return false; - } - } - - return true; - } } } diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs index dc61c7e1..ffb2909e 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs @@ -581,6 +581,134 @@ await That(result.Sources["Mock.MyService.g.cs"]) "Action does not collide with the setup action type (Action)"); } + [Fact] + public async Task WhenConstructorParameterTypeIsProtectedInternalNestedType_ShouldEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(new MyService.Configuration()); + } + } + + public class MyService + { + protected MyService(Configuration configuration) { } + protected internal class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .Contains("CreateMock(global::MyCode.MyService.Configuration configuration)") + .IgnoringNewlineStyle() + .Because("the internal half of `protected internal` is visible within the declaring assembly"); + } + + [Fact] + public async Task WhenConstructorParameterTypeIsProtectedNestedType_ShouldNotEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + public MyService() { } + protected MyService(Configuration configuration) { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .Contains("public MyService(global::Mockolate.MockRegistry mockRegistry)").IgnoringNewlineStyle().And + .DoesNotContain("Configuration") + .Because( + "a protected nested type is only reachable through inheritance, so naming it on the generated public constructor (CS0051) or in MockExtensionsForMyService (CS0122) would not compile"); + } + + [Fact] + public async Task WhenConstructorParameterTypeIsProtectedNestedTypeArgument_ShouldNotEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using System.Collections.Generic; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + public MyService() { } + protected MyService(List configurations) { } + protected MyService(Configuration[] configurations) { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Configuration") + .Because("an inaccessible type is equally unusable when composed into an array or a type argument"); + } + + [Fact] + public async Task WhenOnlyConstructorHasProtectedNestedParameterType_ShouldNotGenerateMock() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + protected MyService(Configuration configuration) { } + protected class Configuration { } + } + """); + + await That(result.Sources).DoesNotContainKey("Mock.MyService.g.cs") + .Because("no constructor remains that the generated mock could invoke"); + } + [Fact] public async Task WhenConstructorsDifferOnlyByNullableValueType_ShouldEmitBothTypedOverloads() { diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs index 81dddfb0..b17a3dcb 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs @@ -6,6 +6,17 @@ public sealed partial class MockTests { public sealed class CrossAssemblyTests { + private const string CreateMockForClientBase = """ + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) => _ = Ext.ClientBase.CreateMock(); + } + """; + private const string CreateMockForMyBaseClass = """ using Mockolate; @@ -91,6 +102,36 @@ await That(result.Sources["Mock.MyBaseClass.g.cs"]) .DoesNotContain("protected internal set"); } + [Fact] + public async Task ProtectedInternalNestedConstructorParameter_WithInternalsVisibleTo_ShouldEmitConstructor() + { + MetadataReference external = CompileClientBaseAssembly(grantsInternalsVisibleTo: true); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.ClientBase.g.cs"); + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("CreateMock(global::Ext.ClientBase.ClientBaseConfiguration configuration)") + .Because("InternalsVisibleTo makes the internal half of `protected internal` visible"); + } + + [Fact] + public async Task ProtectedInternalNestedConstructorParameter_WithoutInternalsVisibleTo_ShouldNotEmitConstructor() + { + MetadataReference external = CompileClientBaseAssembly(grantsInternalsVisibleTo: false); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.ClientBase.g.cs"); + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("public ClientBase(global::Mockolate.MockRegistry mockRegistry)").And + .DoesNotContain("ClientBaseConfiguration") + .Because( + "`protected internal` degrades to `protected` outside the declaring assembly, so the type is only reachable through inheritance and cannot be named on the generated public constructor (CS0051) or in MockExtensionsForClientBase (CS0122)"); + } + [Fact] public async Task PublicMembers_ShouldBeMockedAcrossAssemblyBoundary() { @@ -508,6 +549,24 @@ await That(result.Sources).DoesNotContainKey("Mock.MyExternalType.g.cs") "an `abstract override` re-declaration continues the slot without filling it, so the member is still the mock's obligation"); } + private static MetadataReference CompileClientBaseAssembly(bool grantsInternalsVisibleTo) + { + string internalsVisibleTo = grantsInternalsVisibleTo + ? """[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestAssembly")]""" + : ""; + return ExternalAssembly.Compile($$""" + {{internalsVisibleTo}} + namespace Ext; + + public class ClientBase + { + protected ClientBase() { } + protected ClientBase(ClientBaseConfiguration configuration) { } + protected internal class ClientBaseConfiguration { } + } + """); + } + private static MetadataReference CompileMyExternalTypeAssembly(string typeKeyword, string member, bool grantsInternalsVisibleTo) { From 65e0f695a56ed80c8715555e450b68dbe17d5ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 16:58:50 +0200 Subject: [PATCH 2/5] fix: skip members whose signature names an inaccessible type `IsAccessibleFrom` only inspected the type arguments of the innermost named type, so `Wrapper.Inner` slipped through and still produced CS0051/CS0122. Both the declaration and the type arguments are now checked per nesting level. Apply the same rule to member signatures (return/member type, parameter types, generic constraint types), which the mock restates verbatim on surfaces that do not derive from the mocked type. A virtual member is dropped from the surface, an abstract one makes the type unmockable, mirroring how an inaccessible member is already handled. `MockabilityAnalyzer` reports Mockolate0002 for the same condition so the user gets a diagnostic instead of a missing mock. --- .../MockabilityAnalyzer.cs | 121 +++++++++++-- .../Entities/Class.cs | 8 +- .../Entities/Event.cs | 3 +- .../Entities/Method.cs | 3 +- .../Entities/Property.cs | 3 +- Source/Mockolate.SourceGenerators/Helpers.cs | 66 +++++-- .../MockabilityAnalyzerAccessibilityTests.cs | 34 ++++ .../MockGeneratorTests.cs | 171 ++++++++++++++++++ .../MockTests.CrossAssemblyTests.cs | 55 ++++++ 9 files changed, 424 insertions(+), 40 deletions(-) diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index 0ab90543..68186464 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -466,10 +466,11 @@ private static bool IsMockable(ITypeSymbol typeSymbol, IAssemblySymbol sourceAss return false; } - if (FindInaccessibleRequiredMember(typeSymbol, sourceAssembly) is { } inaccessibleMember) + if (FindInaccessibleRequiredMember(typeSymbol, sourceAssembly) is { } inaccessible) { - reason = - $"the member '{inaccessibleMember.ToDisplayString()}' must be implemented, but it is not accessible from this assembly"; + reason = inaccessible.InaccessibleType is { } inaccessibleType + ? $"the member '{inaccessible.Member.ToDisplayString()}' must be implemented, but its signature uses the type '{inaccessibleType.ToDisplayString()}', which is not accessible from this assembly" + : $"the member '{inaccessible.Member.ToDisplayString()}' must be implemented, but it is not accessible from this assembly"; return false; } @@ -477,7 +478,8 @@ private static bool IsMockable(ITypeSymbol typeSymbol, IAssemblySymbol sourceAss return true; } - private static ISymbol? FindInaccessibleRequiredMember(ITypeSymbol type, IAssemblySymbol sourceAssembly) + private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessibleRequiredMember(ITypeSymbol type, + IAssemblySymbol sourceAssembly) { HashSet filledSlots = new(StringComparer.Ordinal); @@ -559,17 +561,106 @@ private static IEnumerable EnumerateFilledSlots(ISymbol member) } } - private static ISymbol? FindInaccessibleMember(ISymbol member, IAssemblySymbol sourceAssembly) - => member switch - { - IMethodSymbol { MethodKind: MethodKind.Ordinary, IsAbstract: true, } method - => IsAccessibleFrom(method, sourceAssembly) ? null : method, - IPropertySymbol { IsAbstract: true, } property - => FindInaccessibleAccessor(property, sourceAssembly), - IEventSymbol { IsAbstract: true, } @event - => IsAccessibleFrom(@event, sourceAssembly) ? null : @event, - _ => null, - }; + private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessibleMember(ISymbol member, + IAssemblySymbol sourceAssembly) + { + switch (member) + { + case IMethodSymbol { MethodKind: MethodKind.Ordinary, IsAbstract: true, } method: + return !IsAccessibleFrom(method, sourceAssembly) + ? (method, null) + : Combine(method, FindInaccessibleSignatureType(method, sourceAssembly)); + case IPropertySymbol { IsAbstract: true, } property: + return FindInaccessibleAccessor(property, sourceAssembly) is { } accessor + ? (accessor, null) + : Combine(property, FindInaccessibleSignatureType(property, sourceAssembly)); + case IEventSymbol { IsAbstract: true, } @event: + return !IsAccessibleFrom(@event, sourceAssembly) + ? (@event, null) + : Combine(@event, FindInaccessibleSignatureType(@event, sourceAssembly)); + default: + return null; + } + + static (ISymbol Member, ITypeSymbol? InaccessibleType)? Combine(ISymbol member, ITypeSymbol? inaccessibleType) + => inaccessibleType is null ? null : (member, inaccessibleType); + } + + /// + /// The first type named in 's signature that the mock cannot restate, + /// or when the whole signature is reachable. + /// + /// + /// Must stay in sync with Helpers.HasAccessibleSignature and + /// Helpers.IsAccessibleFrom in Source/Mockolate.SourceGenerators/Helpers.cs. The + /// generator refuses to emit a mock whose required member names a type it cannot reference from + /// IMockSetupForXXX / IMockVerifyForXXX / MockExtensionsForXXX, none of + /// which derive from the mocked type. + /// + private static ITypeSymbol? FindInaccessibleSignatureType(ISymbol member, IAssemblySymbol sourceAssembly) + { + switch (member) + { + case IMethodSymbol method: + return FirstInaccessible([ + method.ReturnType, + ..method.Parameters.Select(parameter => parameter.Type), + ..method.TypeParameters.SelectMany(typeParameter => typeParameter.ConstraintTypes), + ]); + case IPropertySymbol property: + return FirstInaccessible([ + property.Type, ..property.Parameters.Select(parameter => parameter.Type), + ]); + case IEventSymbol @event: + return FirstInaccessible([@event.Type,]); + default: + return null; + } + + ITypeSymbol? FirstInaccessible(IEnumerable types) + => types.FirstOrDefault(type => !IsTypeAccessibleFrom(type, sourceAssembly)); + } + + /// + /// Mirror of Helpers.IsAccessibleFrom: a type is reachable only if every type in its + /// containing chain, and every type it is composed of, is either public or internal with access + /// granted. The half of protected / + /// protected internal does not count, because the surfaces naming the type do not derive + /// from the mocked type. + /// + private static bool IsTypeAccessibleFrom(ITypeSymbol type, IAssemblySymbol sourceAssembly) + { + switch (type) + { + case IArrayTypeSymbol array: + return IsTypeAccessibleFrom(array.ElementType, sourceAssembly); + case IPointerTypeSymbol pointer: + return IsTypeAccessibleFrom(pointer.PointedAtType, sourceAssembly); + case INamedTypeSymbol named: + for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) + { + if (!IsDeclarationAccessible(t) || + !t.TypeArguments.All(argument => IsTypeAccessibleFrom(argument, sourceAssembly))) + { + return false; + } + } + + return true; + default: + return true; + } + + bool IsDeclarationAccessible(INamedTypeSymbol candidate) + => candidate.DeclaredAccessibility switch + { + Accessibility.Public => true, + Accessibility.Internal or Accessibility.ProtectedOrInternal => + SymbolEqualityComparer.Default.Equals(candidate.ContainingAssembly, sourceAssembly) || + candidate.ContainingAssembly.GivesAccessTo(sourceAssembly), + _ => false, + }; + } private static ISymbol? FindInaccessibleAccessor(IPropertySymbol property, IAssemblySymbol sourceAssembly) { diff --git a/Source/Mockolate.SourceGenerators/Entities/Class.cs b/Source/Mockolate.SourceGenerators/Entities/Class.cs index 3711ebaa..623a27c9 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Class.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Class.cs @@ -193,10 +193,13 @@ bool ShouldIncludeMember(ISymbol member) if (IsInterface || member.IsAbstract) { + // An abstract member is the mock's obligation, so it is kept even when it cannot be + // restated: `ComputeHasInaccessibleRequiredMember` then rejects the whole type. return true; } - return Helpers.IsOverridableFrom(member, _sourceAssembly); + return Helpers.IsOverridableFrom(member, _sourceAssembly) && + Helpers.HasAccessibleSignature(member, _sourceAssembly); } } @@ -257,7 +260,8 @@ private int ComputeSurfaceHash() /// /// True when a member the mock is still obliged to implement is invisible to the mock's - /// assembly, leaving no valid code the generator could emit for it. + /// assembly - either the member itself, or a type named in its signature - leaving no valid code + /// the generator could emit for it. /// /// /// Deliberately reads the filtered // diff --git a/Source/Mockolate.SourceGenerators/Entities/Event.cs b/Source/Mockolate.SourceGenerators/Entities/Event.cs index bb84c982..93a5253b 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Event.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Event.cs @@ -12,7 +12,8 @@ public Event(IEventSymbol eventSymbol, IMethodSymbol delegateInvokeMethod, List< OverrideAccessibility = Helpers.ResolveOverrideVisibility( Accessibility, eventSymbol.ContainingAssembly, sourceAssembly); UseOverride = eventSymbol.IsVirtual || eventSymbol.IsAbstract; - IsOverridableFromMock = Helpers.IsOverridableFrom(eventSymbol, sourceAssembly); + IsOverridableFromMock = Helpers.IsOverridableFrom(eventSymbol, sourceAssembly) && + Helpers.HasAccessibleSignature(eventSymbol, sourceAssembly); IsAbstract = eventSymbol.IsAbstract; Name = Helpers.EscapeIfKeyword(eventSymbol.ExplicitInterfaceImplementations.Length > 0 ? eventSymbol.ExplicitInterfaceImplementations[0].Name : eventSymbol.Name); Type = Type.From(eventSymbol.Type); diff --git a/Source/Mockolate.SourceGenerators/Entities/Method.cs b/Source/Mockolate.SourceGenerators/Entities/Method.cs index 67afd3b4..41c7cc54 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Method.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Method.cs @@ -12,7 +12,8 @@ public Method(IMethodSymbol methodSymbol, List? alreadyDefinedMethods, I OverrideAccessibility = Helpers.ResolveOverrideVisibility( Accessibility, methodSymbol.ContainingAssembly, sourceAssembly); UseOverride = methodSymbol.IsVirtual || methodSymbol.IsAbstract; - IsOverridableFromMock = Helpers.IsOverridableFrom(methodSymbol, sourceAssembly); + IsOverridableFromMock = Helpers.IsOverridableFrom(methodSymbol, sourceAssembly) && + Helpers.HasAccessibleSignature(methodSymbol, sourceAssembly); IsAbstract = methodSymbol.IsAbstract; IsStatic = methodSymbol.IsStatic; IsInitOnly = methodSymbol.IsInitOnly; diff --git a/Source/Mockolate.SourceGenerators/Entities/Property.cs b/Source/Mockolate.SourceGenerators/Entities/Property.cs index ef9ccf88..1e535767 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Property.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Property.cs @@ -42,7 +42,8 @@ public Property(IPropertySymbol propertySymbol, List? alreadyDefinedPr bool setterOverridable = propertySymbol.SetMethod is not { } setterSymbol || Helpers.IsOverridableFrom(setterSymbol, sourceAssembly); IsOverridableFromMock = getterOverridable && setterOverridable && - Helpers.IsOverridableFrom(propertySymbol, sourceAssembly); + Helpers.IsOverridableFrom(propertySymbol, sourceAssembly) && + Helpers.HasAccessibleSignature(propertySymbol, sourceAssembly); Getter = propertySymbol.GetMethod is { } getter && getterOverridable ? new Method(getter, null, sourceAssembly) diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 6e3780a2..b955deac 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -123,12 +123,13 @@ public static string ResolveOverrideVisibility(Accessibility accessibility, /// /// Conservative visibility test for a type that the generator names verbatim in emitted code - /// (attribute names, constructor parameter types). A type is accessible only if its whole - /// containing chain, and every type it is composed of (array element, type argument), is - /// either Public, or Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the same - /// assembly). Private/Protected/ProtectedAndInternal nested types, and ProtectedOrInternal - /// across assemblies without IVT, are treated as inaccessible: the protected half is - /// only reachable by deriving from the declaring type, which the surfaces naming the type + /// (attribute names, constructor parameter types, member signatures). A type is accessible only + /// if every type in its containing chain, and every type it is composed of (array element, + /// pointed-at type, type argument at any nesting level), is either Public, or + /// Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the same assembly). + /// Private/Protected/ProtectedAndInternal nested types, and ProtectedOrInternal across + /// assemblies without IVT, are treated as inaccessible: the protected half is only + /// reachable by deriving from the declaring type, which the surfaces naming the type /// (e.g. MockExtensionsForXXX) do not do. /// public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAssembly) @@ -140,31 +141,56 @@ public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAss case IPointerTypeSymbol pointer: return IsAccessibleFrom(pointer.PointedAtType, sourceAssembly); case INamedTypeSymbol named: + // Both the declaration and the type arguments are checked per nesting level: for + // `Outer.Inner` the arguments sit on the containing type, so inspecting only + // `named.TypeArguments` would miss them. for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) { - switch (t.DeclaredAccessibility) + if (!IsDeclarationAccessible(t) || + !t.TypeArguments.All(argument => IsAccessibleFrom(argument, sourceAssembly))) { - case Accessibility.Public: - continue; - case Accessibility.Internal: - case Accessibility.ProtectedOrInternal: - if (sourceAssembly is null || HasInternalAccess(t.ContainingAssembly, sourceAssembly)) - { - continue; - } - - return false; - default: - return false; + return false; } } - return named.TypeArguments.All(argument => IsAccessibleFrom(argument, sourceAssembly)); + return true; default: return true; } + + bool IsDeclarationAccessible(INamedTypeSymbol candidate) + => candidate.DeclaredAccessibility switch + { + Accessibility.Public => true, + Accessibility.Internal or Accessibility.ProtectedOrInternal => + sourceAssembly is null || HasInternalAccess(candidate.ContainingAssembly, sourceAssembly), + _ => false, + }; } + /// + /// True when every type named in 's signature (return/member type, + /// parameter types, generic constraint types) is accessible per . + /// + /// + /// The mock restates those types verbatim on surfaces that do not derive from the mocked + /// type (IMockSetupForXXX, IMockVerifyForXXX, MockExtensionsForXXX), so a + /// nested type in a signature causes CS0122 there even though the + /// inside the mock class itself would compile. + /// + public static bool HasAccessibleSignature(ISymbol member, IAssemblySymbol? sourceAssembly) + => member switch + { + IMethodSymbol method => IsAccessibleFrom(method.ReturnType, sourceAssembly) && + method.Parameters.All(p => IsAccessibleFrom(p.Type, sourceAssembly)) && + method.TypeParameters.All(p => p.ConstraintTypes + .All(c => IsAccessibleFrom(c, sourceAssembly))), + IPropertySymbol property => IsAccessibleFrom(property.Type, sourceAssembly) && + property.Parameters.All(p => IsAccessibleFrom(p.Type, sourceAssembly)), + IEventSymbol @event => IsAccessibleFrom(@event.Type, sourceAssembly), + _ => true, + }; + private static bool HasInternalAccess(IAssemblySymbol? containingAssembly, IAssemblySymbol? sourceAssembly) { if (sourceAssembly is null || containingAssembly is null) diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs index b8eaa33d..9630c5f6 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs @@ -151,6 +151,40 @@ public async Task WhenProtectedInternalAbstractMember_ShouldNotBeFlagged() => aw MockCreation, ExternalType("abstract class", "protected internal abstract void MyMember();")); + [Theory] + [InlineData("protected abstract void MyMember(MyConfiguration configuration);", + "Ext.MyExternalType.MyMember(Ext.MyExternalType.MyConfiguration)", "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract MyConfiguration MyMember();", "Ext.MyExternalType.MyMember()", + "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract MyConfiguration MyMember { get; set; }", "Ext.MyExternalType.MyMember", + "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract void MyMember() where T : MyConfiguration;", + "Ext.MyExternalType.MyMember()", "Ext.MyExternalType.MyConfiguration")] + [InlineData("protected abstract System.Collections.Generic.List MyMember();", + "Ext.MyExternalType.MyMember()", "System.Collections.Generic.List")] + public async Task WhenAbstractMemberSignatureUsesAnInaccessibleType_ShouldBeFlagged( + string member, string reportedMember, string reportedType) => await Verifier + .VerifyAnalyzerWithReferencedProjectAsync( + MockCreation, + ExternalType("abstract class", $$""" + {{member}} + protected internal class MyConfiguration { } + """), + Verifier.Diagnostic(Rules.MockabilityRule) + .WithLocation(0) + .WithArguments("Ext.MyExternalType", + $"the member '{reportedMember}' must be implemented, but its signature uses the type '{reportedType}', which is not accessible from this assembly") + ); + + [Fact] + public async Task WhenAbstractMemberSignatureUsesATypeVisibleToTheMockAssembly_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerWithReferencedProjectAsync( + MockCreation, + ExternalType("abstract class", """ + protected abstract void MyMember(MyConfiguration configuration); + protected internal class MyConfiguration { } + """, internalsVisibleToTestProject: true)); + [Theory] [InlineData("private protected abstract void MyMember();", "Ext.MyExternalType.MyMember()")] [InlineData("private protected abstract int MyMember { get; set; }", "Ext.MyExternalType.MyMember.get")] diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs index ffb2909e..a61ac129 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs @@ -709,6 +709,177 @@ await That(result.Sources).DoesNotContainKey("Mock.MyService.g.cs") .Because("no constructor remains that the generated mock could invoke"); } + [Fact] + public async Task WhenConstructorParameterTypeNestsInProtectedTypeArgumentOfContainingType_ShouldNotEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class Wrapper + { + public class Inner { } + } + + public class MyService + { + public MyService() { } + protected MyService(Wrapper.Inner inner) { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Configuration") + .Because( + "`Wrapper.Inner` carries the type argument on its containing type, so checking only the innermost type's arguments would let the inaccessible type through"); + } + + [Fact] + public async Task WhenConstructorParameterTypeIsInternalInSameAssembly_ShouldEmitConstructor() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(new Configuration()); + } + } + + internal class Configuration { } + + internal class MyService + { + protected MyService(Configuration configuration) { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .Contains("CreateMock(global::MyCode.Configuration configuration)") + .IgnoringNewlineStyle() + .Because("the generated mock lives in the same assembly, so an internal parameter type is nameable"); + } + + [Fact] + public async Task WhenVirtualMemberSignatureUsesProtectedNestedType_ShouldNotMockMember() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + protected virtual void Consume(Configuration configuration) { } + protected virtual Configuration Produce() => new Configuration(); + protected virtual Configuration Current { get; set; } + protected virtual void Constrained() where T : Configuration { } + protected class Configuration { } + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Configuration").And + .DoesNotContain("Consume").And + .DoesNotContain("Produce").And + .DoesNotContain("Current").And + .DoesNotContain("Constrained") + .Because( + "the setup and verify surfaces restate the signature verbatim but do not derive from the mocked type, so a protected nested type would cause CS0122 there"); + } + + [Fact] + public async Task WhenVirtualEventTypeUsesProtectedNestedType_ShouldNotMockEvent() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public class MyService + { + protected virtual event Handler Changed; + protected void Fire() => Changed?.Invoke(); + protected delegate void Handler(); + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.MyService.g.cs"); + await That(result.Sources["Mock.MyService.g.cs"]) + .DoesNotContain("Changed").And + .DoesNotContain("Handler") + .Because("the raise surface names the delegate type verbatim"); + } + + [Fact] + public async Task WhenAbstractMemberSignatureUsesProtectedNestedType_ShouldNotGenerateMock() + { + GeneratorResult result = Generator + .Run(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = MyService.CreateMock(); + } + } + + public abstract class MyService + { + protected abstract void Consume(Configuration configuration); + protected class Configuration { } + } + """); + + await That(result.Sources).DoesNotContainKey("Mock.MyService.g.cs") + .Because("the member must be implemented, but the mock cannot restate its signature"); + } + [Fact] public async Task WhenConstructorsDifferOnlyByNullableValueType_ShouldEmitBothTypedOverloads() { diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs index b17a3dcb..11468786 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs @@ -132,6 +132,60 @@ await That(result.Sources["Mock.ClientBase.g.cs"]) "`protected internal` degrades to `protected` outside the declaring assembly, so the type is only reachable through inheritance and cannot be named on the generated public constructor (CS0051) or in MockExtensionsForClientBase (CS0122)"); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProtectedInternalNestedMemberSignature_ShouldFollowInternalsVisibleTo( + bool grantsInternalsVisibleTo) + { + MetadataReference external = CompileClientBaseAssembly(grantsInternalsVisibleTo); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.ClientBase.g.cs"); + if (grantsInternalsVisibleTo) + { + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("ApplyOptions") + .Because("InternalsVisibleTo makes the internal half of `protected internal` visible"); + } + else + { + await That(result.Sources["Mock.ClientBase.g.cs"]) + .DoesNotContain("ApplyOptions") + .Because( + "the setup and verify surfaces would have to name `ClientBaseConfiguration`, which degrades to `protected` outside the declaring assembly (CS0122)"); + } + } + + [Fact] + public async Task AttributeWithInaccessibleTypeArgument_ShouldNotBeEmitted() + { + MetadataReference external = ExternalAssembly.Compile(""" + namespace Ext; + + internal class Secret { } + + public class MyMarkerAttribute : System.Attribute { } + + public class ClientBase + { + [MyMarkerAttribute] + public virtual void Send() { } + } + """); + + GeneratorResult result = Generator.RunWithReferences(CreateMockForClientBase, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources["Mock.ClientBase.g.cs"]) + .Contains("Send").And + .DoesNotContain("MyMarkerAttribute") + .Because( + "the attribute name is emitted verbatim, so an inaccessible type argument makes it unusable even though the attribute class itself is public"); + } + [Fact] public async Task PublicMembers_ShouldBeMockedAcrossAssemblyBoundary() { @@ -562,6 +616,7 @@ public class ClientBase { protected ClientBase() { } protected ClientBase(ClientBaseConfiguration configuration) { } + protected virtual void ApplyOptions(ClientBaseConfiguration configuration) { } protected internal class ClientBaseConfiguration { } } """); From 18bd9803278f48f1cd16862ca80a10a6db731aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 17:23:36 +0200 Subject: [PATCH 3/5] chore: condense the comments on the accessibility helpers Collapse the `` blocks into their summaries and drop the restatements of C# accessibility rules, keeping only the CS-number rationale. --- .../Entities/MockClass.cs | 8 ++--- Source/Mockolate.SourceGenerators/Helpers.cs | 35 +++++++------------ 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/Source/Mockolate.SourceGenerators/Entities/MockClass.cs b/Source/Mockolate.SourceGenerators/Entities/MockClass.cs index d55cce84..b8393be4 100644 --- a/Source/Mockolate.SourceGenerators/Entities/MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Entities/MockClass.cs @@ -24,11 +24,9 @@ public MockClass(ITypeSymbol[] types, IAssemblySymbol sourceAssembly) : base(typ .Where(x => x.DeclaredAccessibility == Accessibility.Protected || x.DeclaredAccessibility == Accessibility.ProtectedOrInternal || x.DeclaredAccessibility == Accessibility.Public) - // A constructor parameter type is named verbatim in `MockExtensionsForXXX`, which does - // not derive from the mocked type: a type that is only reachable through inheritance - // (a `protected` nested type, or a `protected internal` one across assemblies) would - // cause CS0122 there and CS0051 on the generated `public` constructor. Such a - // constructor cannot be driven from the outside at all, so drop it entirely. + // Parameter types are named verbatim in `MockExtensionsForXXX`, which does not derive + // from the mocked type: a type reachable only through inheritance would cause CS0122 + // there and CS0051 on the generated `public` constructor. .Where(x => x.Parameters.All(p => Helpers.IsAccessibleFrom(p.Type, sourceAssembly))) .Select(x => new Method(x, null, sourceAssembly)).ToArray()); if (namedTypeSymbol.DelegateInvokeMethod is not null) diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index b955deac..621a971e 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -122,15 +122,11 @@ public static string ResolveOverrideVisibility(Accessibility accessibility, }; /// - /// Conservative visibility test for a type that the generator names verbatim in emitted code - /// (attribute names, constructor parameter types, member signatures). A type is accessible only - /// if every type in its containing chain, and every type it is composed of (array element, - /// pointed-at type, type argument at any nesting level), is either Public, or - /// Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the same assembly). - /// Private/Protected/ProtectedAndInternal nested types, and ProtectedOrInternal across - /// assemblies without IVT, are treated as inaccessible: the protected half is only - /// reachable by deriving from the declaring type, which the surfaces naming the type - /// (e.g. MockExtensionsForXXX) do not do. + /// Conservative visibility test for a type the generator names verbatim (attribute names, + /// constructor parameter types, member signatures): every type in the containing chain and + /// every composed type (array element, pointed-at type, type argument) must be public, or + /// internal/protected internal with access granted. The protected half never counts, + /// because the surfaces naming the type do not derive from the mocked type. /// public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAssembly) { @@ -141,9 +137,8 @@ public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAss case IPointerTypeSymbol pointer: return IsAccessibleFrom(pointer.PointedAtType, sourceAssembly); case INamedTypeSymbol named: - // Both the declaration and the type arguments are checked per nesting level: for - // `Outer.Inner` the arguments sit on the containing type, so inspecting only - // `named.TypeArguments` would miss them. + // For `Outer.Inner` the arguments sit on the containing type, so + // `named.TypeArguments` alone would miss them. for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType) { if (!IsDeclarationAccessible(t) || @@ -171,13 +166,11 @@ bool IsDeclarationAccessible(INamedTypeSymbol candidate) /// /// True when every type named in 's signature (return/member type, /// parameter types, generic constraint types) is accessible per . - /// - /// - /// The mock restates those types verbatim on surfaces that do not derive from the mocked - /// type (IMockSetupForXXX, IMockVerifyForXXX, MockExtensionsForXXX), so a - /// nested type in a signature causes CS0122 there even though the + /// The mock restates them on surfaces that do not derive from the mocked type + /// (IMockSetupForXXX, IMockVerifyForXXX, MockExtensionsForXXX), so a + /// nested type causes CS0122 there even though the /// inside the mock class itself would compile. - /// + /// public static bool HasAccessibleSignature(ISymbol member, IAssemblySymbol? sourceAssembly) => member switch { @@ -333,10 +326,8 @@ public bool HasReservedName(string candidate) { public EquatableArray? ToAttributeArray(IAssemblySymbol? sourceAssembly = null) { - // The attribute name is emitted verbatim into the generated code (e.g. - // `[global::Azure.Core.CallerShouldAudit(...)]`), so an attribute class that is not visible - // to the generated mock assembly would cause CS0122. Drop it instead of producing - // uncompilable output. + // The attribute name is emitted verbatim, so an attribute class invisible to the mock's + // assembly would cause CS0122. Drop it instead of emitting uncompilable code. Attribute[] consideredAttributes = attributes .Where(x => x.AttributeClass is not null && !IsCompilerEmittedAttribute(x.AttributeClass) From b1d32f7c8b17a92fda4f5ed1ff13550f782efc76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 17:24:03 +0200 Subject: [PATCH 4/5] fix: do not reject a type whose inaccessible slot a derived member fills `IsSlotReachable` judged only the base declaration's own accessibility, so folding `HasAccessibleSignature` into `IsOverridableFromMock` made a concrete class unmockable whenever a base slot it already overrides names a type the mock cannot restate: public abstract class Base { protected abstract void Consume(Configuration configuration); protected class Configuration { } } public class Derived : Base { protected override void Consume(Configuration configuration) { } } `Derived` has no obligation left, yet the generator emitted nothing while `MockabilityAnalyzer` - which skips filled slots unconditionally - reported no Mockolate0002, leaving only a bare CS0117 on `CreateMock()`. Teach the slot check about the signature so both sides agree. Also guard the analyzer's containing-assembly lookup, matching the `HasInternalAccess` mirror in the generator. --- .../MockabilityAnalyzer.cs | 21 +++------ .../Entities/Class.cs | 8 ++-- .../MockabilityAnalyzerAccessibilityTests.cs | 25 +++++++++++ .../MockGeneratorTests.cs | 45 +++++++++++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index 68186464..a197dee3 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -588,15 +588,9 @@ private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessible /// /// The first type named in 's signature that the mock cannot restate, - /// or when the whole signature is reachable. + /// or when the whole signature is reachable. Mirrors + /// Helpers.HasAccessibleSignature in the source generator; keep both in sync. /// - /// - /// Must stay in sync with Helpers.HasAccessibleSignature and - /// Helpers.IsAccessibleFrom in Source/Mockolate.SourceGenerators/Helpers.cs. The - /// generator refuses to emit a mock whose required member names a type it cannot reference from - /// IMockSetupForXXX / IMockVerifyForXXX / MockExtensionsForXXX, none of - /// which derive from the mocked type. - /// private static ITypeSymbol? FindInaccessibleSignatureType(ISymbol member, IAssemblySymbol sourceAssembly) { switch (member) @@ -622,11 +616,10 @@ private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessible } /// - /// Mirror of Helpers.IsAccessibleFrom: a type is reachable only if every type in its - /// containing chain, and every type it is composed of, is either public or internal with access - /// granted. The half of protected / - /// protected internal does not count, because the surfaces naming the type do not derive - /// from the mocked type. + /// Mirror of Helpers.IsAccessibleFrom: every type in the containing chain and every + /// composed type must be public, or internal/protected internal with access granted. The + /// half never counts, because the surfaces naming the type do not + /// derive from the mocked type. /// private static bool IsTypeAccessibleFrom(ITypeSymbol type, IAssemblySymbol sourceAssembly) { @@ -657,7 +650,7 @@ bool IsDeclarationAccessible(INamedTypeSymbol candidate) Accessibility.Public => true, Accessibility.Internal or Accessibility.ProtectedOrInternal => SymbolEqualityComparer.Default.Equals(candidate.ContainingAssembly, sourceAssembly) || - candidate.ContainingAssembly.GivesAccessTo(sourceAssembly), + candidate.ContainingAssembly?.GivesAccessTo(sourceAssembly) == true, _ => false, }; } diff --git a/Source/Mockolate.SourceGenerators/Entities/Class.cs b/Source/Mockolate.SourceGenerators/Entities/Class.cs index 623a27c9..6ba8d3c9 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Class.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Class.cs @@ -193,8 +193,8 @@ bool ShouldIncludeMember(ISymbol member) if (IsInterface || member.IsAbstract) { - // An abstract member is the mock's obligation, so it is kept even when it cannot be - // restated: `ComputeHasInaccessibleRequiredMember` then rejects the whole type. + // An abstract member is kept even when it cannot be restated; + // `ComputeHasInaccessibleRequiredMember` then rejects the whole type. return true; } @@ -284,7 +284,8 @@ private bool ComputeHasInaccessibleRequiredMember(List filledPropertie /// /// True when fills a base slot (by or by /// explicit interface implementation) that the mock must leave alone entirely, because the base - /// declaration or one of its accessors is invisible to . + /// declaration, one of its accessors, or a type in its signature is invisible to + /// . /// private static bool FillsInaccessibleBaseSlot(ISymbol member, IAssemblySymbol? sourceAssembly) { @@ -363,6 +364,7 @@ private static IEnumerable EnumerateFilledSlots(ISymbol member) private static bool IsSlotReachable(ISymbol slot, IAssemblySymbol? sourceAssembly) => Helpers.IsOverridableFrom(slot, sourceAssembly) && + Helpers.HasAccessibleSignature(slot, sourceAssembly) && (slot is not IPropertySymbol property || !HasUnreachableAccessor(property, sourceAssembly)); private static bool HasUnreachableAccessor(IPropertySymbol property, IAssemblySymbol? sourceAssembly) diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs index 9630c5f6..cd7c08a9 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerAccessibilityTests.cs @@ -185,6 +185,31 @@ public async Task WhenAbstractMemberSignatureUsesATypeVisibleToTheMockAssembly_S protected internal class MyConfiguration { } """, internalsVisibleToTestProject: true)); + [Theory] + [InlineData("protected abstract void MyMember(MyConfiguration configuration);", + "protected override void MyMember(MyConfiguration configuration) { }")] + [InlineData("protected abstract MyConfiguration MyMember { get; set; }", + "protected override MyConfiguration MyMember { get; set; }")] + public async Task WhenAbstractMemberWithInaccessibleSignatureIsAlreadyOverridden_ShouldNotBeFlagged( + string baseMember, string derivedOverride) => await Verifier + .VerifyAnalyzerWithReferencedProjectAsync( + MockCreation, + $$""" + namespace Ext + { + public abstract class MyBaseType + { + {{baseMember}} + protected internal class MyConfiguration { } + } + + public class MyExternalType : MyBaseType + { + {{derivedOverride}} + } + } + """); + [Theory] [InlineData("private protected abstract void MyMember();", "Ext.MyExternalType.MyMember()")] [InlineData("private protected abstract int MyMember { get; set; }", "Ext.MyExternalType.MyMember.get")] diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs index a61ac129..b3f11f07 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockGeneratorTests.cs @@ -880,6 +880,51 @@ await That(result.Sources).DoesNotContainKey("Mock.MyService.g.cs") .Because("the member must be implemented, but the mock cannot restate its signature"); } + [Theory] + [InlineData("protected abstract void Consume(Configuration configuration);", + "protected override void Consume(Configuration configuration) { }")] + [InlineData("protected abstract Configuration Current { get; set; }", + "protected override Configuration Current { get; set; }")] + [InlineData("protected abstract event Handler Changed;", + "protected override event Handler Changed; protected void Fire() => Changed?.Invoke();")] + public async Task WhenAbstractMemberWithInaccessibleSignatureIsAlreadyOverridden_ShouldGenerateMock( + string baseMember, string derivedOverride) + { + GeneratorResult result = Generator + .Run($$""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = Derived.CreateMock(); + } + } + + public abstract class Base + { + {{baseMember}} + protected class Configuration { } + protected delegate void Handler(); + } + + public class Derived : Base + { + {{derivedOverride}} + } + """); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.Derived.g.cs") + .Because("`Derived` already fills the slot, so restating the signature is not the mock's obligation"); + await That(result.Sources["Mock.Derived.g.cs"]) + .DoesNotContain("Configuration").And + .DoesNotContain("Handler"); + } + [Fact] public async Task WhenConstructorsDifferOnlyByNullableValueType_ShouldEmitBothTypedOverloads() { From 1e47e6ed4d59349e4530315ca98f9db0bd11d255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 25 Aug 2026 17:58:25 +0200 Subject: [PATCH 5/5] fix: skip a partly reachable slot whose signature names an inaccessible type --- .../Entities/Class.cs | 1 + .../MockTests.CrossAssemblyTests.cs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Source/Mockolate.SourceGenerators/Entities/Class.cs b/Source/Mockolate.SourceGenerators/Entities/Class.cs index 6ba8d3c9..b2d048eb 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Class.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Class.cs @@ -310,6 +310,7 @@ private static bool FillsInaccessibleBaseSlot(ISymbol member, IAssemblySymbol? s IAssemblySymbol? sourceAssembly) => member is IPropertySymbol { IsAbstract: false, OverriddenProperty: { } slot, } && Helpers.IsOverridableFrom(slot, sourceAssembly) && + Helpers.HasAccessibleSignature(slot, sourceAssembly) && HasUnreachableAccessor(slot, sourceAssembly) ? slot : null; diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs index 11468786..11dfe843 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.CrossAssemblyTests.cs @@ -603,6 +603,44 @@ await That(result.Sources).DoesNotContainKey("Mock.MyExternalType.g.cs") "an `abstract override` re-declaration continues the slot without filling it, so the member is still the mock's obligation"); } + [Fact] + public async Task PartlyReachableSlot_WithInaccessibleSignatureType_ShouldSkipMember() + { + MetadataReference external = ExternalAssembly.Compile(""" + namespace Ext; + + public abstract class Base + { + protected abstract Configuration Current { get; private protected set; } + protected class Configuration { } + } + + public class Derived : Base + { + protected override Configuration Current { get; private protected set; } + } + """); + + GeneratorResult result = Generator.RunWithReferences(""" + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) => _ = Ext.Derived.CreateMock(); + } + """, [external,]); + + await That(result.Diagnostics).IsEmpty(); + await That(result.Sources).ContainsKey("Mock.Derived.g.cs"); + await That(result.Sources["Mock.Derived.g.cs"]) + .DoesNotContain("Configuration").And + .DoesNotContain("Current") + .Because( + "the reachable getter could be restated in the mock class, but the setup and verify surfaces would have to name `Configuration`, which is only reachable through inheritance (CS0122)"); + } + private static MetadataReference CompileClientBaseAssembly(bool grantsInternalsVisibleTo) { string internalsVisibleTo = grantsInternalsVisibleTo