Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions src/TUnit.Mocks.Analyzers/InaccessibleConstructorMockAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace TUnit.Mocks.Analyzers;

/// <summary>
/// Reports TM006 when a class is mocked but declares no constructor a generated subclass could
/// chain to. The generator skips code generation for such types, so without this diagnostic the
/// only feedback would be a missing member at the call site. See issue #6493.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class InaccessibleConstructorMockAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(Rules.TM006_CannotMockTypeWithoutAccessibleConstructor);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}

private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
if (context.Node is not InvocationExpressionSyntax invocation)
{
return;
}

var target = ResolveMockTarget(context, invocation);

if (target is not { TypeKind: TypeKind.Class } namedType)
{
return;
}

// Sealed types and value types are already covered by TM001/TM002 — don't double-report.
if (namedType.IsSealed || namedType.IsValueType)
{
return;
}

if (HasAccessibleConstructor(namedType, context.Compilation.Assembly))
{
return;
}

context.ReportDiagnostic(
Diagnostic.Create(
Rules.TM006_CannotMockTypeWithoutAccessibleConstructor,
invocation.GetLocation(),
namedType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)
)
);
}

/// <summary>
/// Resolves the mocked type from either entry point: the generic <c>Mock.Of&lt;T&gt;()</c> /
/// <c>Mock.Wrap&lt;T&gt;()</c> form, or the generated <c>T.Mock()</c> static extension.
/// </summary>
private static INamedTypeSymbol? ResolveMockTarget(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation)
{
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken);

if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
{
return null;
}

if (IsMockEntryPointMethod(methodSymbol))
{
// The multi-type overloads — Of<T1, T2>() through Of<T1, T2, T3, T4>() — return
// Mock<T1>: T1 is the type the impl subclasses, T2..T4 are interfaces layered on it,
// and MockTypeDiscovery reuses T1's constructors for the multi-type model. So the
// first type argument is the constructor-bearing target for every overload.
return methodSymbol.TypeArguments.Length >= 1
? methodSymbol.TypeArguments[0] as INamedTypeSymbol
: null;
}

// Generated per-type entry point: `T.Mock()`.
if (!IsGeneratedStaticEntryPoint(methodSymbol)
|| invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
return null;
}

return context.SemanticModel.GetSymbolInfo(memberAccess.Expression, context.CancellationToken).Symbol
as INamedTypeSymbol;
}

/// <summary>
/// Matches the <c>Mock()</c> the generator emits: a static member of a C# 14
/// <c>extension(T)</c> block inside a <c>*_MockStaticExtension</c> class in namespace
/// <c>TUnit.Mocks</c>. Roslyn reports the member's immediate containing type as the
/// synthesised, unnamed extension type, so the check walks out to the declaring class —
/// matching on the namespace alone would claim any static <c>Mock()</c> a consumer happens to
/// declare there.
/// </summary>
private static bool IsGeneratedStaticEntryPoint(IMethodSymbol method)
{
if (method is not { Name: "Mock", IsStatic: true } || !IsTUnitMocksNamespace(method.ContainingNamespace))
{
return false;
}

for (var containing = method.ContainingType; containing is not null; containing = containing.ContainingType)
{
if (containing.Name.EndsWith("_MockStaticExtension", System.StringComparison.Ordinal))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
{
return true;
}
}

return false;
}

private static bool IsTUnitMocksNamespace(INamespaceSymbol? ns)
=> ns is { Name: "Mocks", ContainingNamespace: { Name: "TUnit", ContainingNamespace.IsGlobalNamespace: true } };

private static bool IsMockEntryPointMethod(IMethodSymbol method)
{
return method.Name is "Of" or "Wrap"
&& method.ContainingType is { Name: "Mock" or "MockRepository", ContainingNamespace: { Name: "Mocks", ContainingNamespace: { Name: "TUnit", ContainingNamespace.IsGlobalNamespace: true } } }
&& method.IsGenericMethod;
}

/// <summary>
/// Mirrors the generator's constructor discovery — <c>MemberDiscovery.DiscoverConstructors</c>
/// → <c>IsMemberAccessible</c> → <c>AreMemberSignatureTypesAccessible</c>. A generated subclass
/// can chain to a constructor only when the constructor itself is reachable AND every one of
/// its parameter types is: the generator drops a constructor whose signature mentions an
/// inaccessible type, and a target left with none is exactly the case this rule reports.
/// Keep the two in step; they live in separate assemblies with no shared project.
/// Protected (and protected internal) constructors are reachable precisely because the
/// generated impl derives from the target.
/// </summary>
private static bool HasAccessibleConstructor(INamedTypeSymbol type, IAssemblySymbol compilationAssembly)
{
return type.InstanceConstructors.Any(ctor => IsChainable(ctor, compilationAssembly));
}

private static bool IsChainable(IMethodSymbol ctor, IAssemblySymbol compilationAssembly)
{
// DiscoverConstructors rejects private constructors outright, same-assembly or not —
// a subclass can never chain to one.
if (ctor.DeclaredAccessibility == Accessibility.Private)
{
return false;
}

return IsAssemblyReachable(ctor.DeclaredAccessibility, ctor.ContainingAssembly, compilationAssembly)
&& ctor.Parameters.All(p => IsTypeAccessible(p.Type, compilationAssembly));
}

/// <summary>
/// Whether a symbol with this accessibility, declared in <paramref name="declaringAssembly"/>,
/// is reachable from <paramref name="compilationAssembly"/>. Anything in the same assembly is;
/// across assemblies only <c>internal</c> and <c>private protected</c> are gated (and then only
/// without InternalsVisibleTo).
/// </summary>
private static bool IsAssemblyReachable(Accessibility accessibility, IAssemblySymbol? declaringAssembly, IAssemblySymbol compilationAssembly)
{
if (declaringAssembly is null || SymbolEqualityComparer.Default.Equals(declaringAssembly, compilationAssembly))
{
return true;
}

if (accessibility == Accessibility.Private)
{
return false;
}

return accessibility is not (Accessibility.Internal or Accessibility.ProtectedAndInternal)
|| declaringAssembly.GivesAccessTo(compilationAssembly);
}

private static bool IsTypeAccessible(ITypeSymbol type, IAssemblySymbol compilationAssembly)
{
// Type parameters are always accessible.
if (type is ITypeParameterSymbol)
{
return true;
}

// Pointer types can't appear in a generated override signature, even same-assembly.
if (type is IPointerTypeSymbol or IFunctionPointerTypeSymbol)
{
return false;
}

if (type is IArrayTypeSymbol arrayType)
{
return IsTypeAccessible(arrayType.ElementType, compilationAssembly);
}

if (!IsAssemblyReachable(type.DeclaredAccessibility, type.ContainingAssembly, compilationAssembly))
{
return false;
}

return type is not INamedTypeSymbol namedType
|| namedType.TypeArguments.All(arg => IsTypeAccessible(arg, compilationAssembly));
}
}
10 changes: 10 additions & 0 deletions src/TUnit.Mocks.Analyzers/Rules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
public static readonly DiagnosticDescriptor TM003_OfDelegateRequiresDelegateType = new(
id: "TM003",
title: "Mock.OfDelegate<T>() requires a delegate type",
messageFormat: "Mock.OfDelegate<T>() requires T to be a delegate type, but '{0}' is not a delegate.",

Check warning on line 30 in src/TUnit.Mocks.Analyzers/Rules.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period

Check warning on line 30 in src/TUnit.Mocks.Analyzers/Rules.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period

Check warning on line 30 in src/TUnit.Mocks.Analyzers/Rules.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period
category: "TUnit.Mocks",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
Expand Down Expand Up @@ -55,10 +55,20 @@
description: "Non-nullable value types can never be null, so Arg.IsNull<T>() will always return false and Arg.IsNotNull<T>() will always return true. Use the nullable form (e.g. int?) to match nullable value type parameters."
);

public static readonly DiagnosticDescriptor TM006_CannotMockTypeWithoutAccessibleConstructor = new(
id: "TM006",
title: "Cannot mock type without an accessible constructor",
messageFormat: "Cannot mock '{0}' because it has no accessible constructor. Use a factory method or model-builder the library provides instead.",
category: "TUnit.Mocks",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "TUnit.Mocks generates a subclass to intercept calls, and every constructor of a subclass must chain to a base constructor. When all of the target's constructors are private (or internal in another assembly), no subclass can be declared, so the type cannot be mocked. Many libraries expose a factory for such types (e.g. Azure's ServiceBusModelFactory) — use that to build the value instead."
);

public static readonly DiagnosticDescriptor TM007_CannotMockInterfaceWithInaccessibleMember = new(
id: "TM007",
title: "Cannot mock interface with an inaccessible member",
messageFormat: "Cannot mock '{0}' because its member '{1}' is not accessible here and cannot be implemented.",

Check warning on line 71 in src/TUnit.Mocks.Analyzers/Rules.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period

Check warning on line 71 in src/TUnit.Mocks.Analyzers/Rules.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period
category: "TUnit.Mocks",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
Expand Down
17 changes: 17 additions & 0 deletions src/TUnit.Mocks.SourceGenerator/MockGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ namespace TUnit.Mocks.Generated;
// Delegate mock: generate members and delegate factory (no impl class)
GenerateDelegateMock(spc, model);
}
else if (model.LacksAccessibleConstructor)
{
// Unsubclassable class (every constructor private / cross-assembly internal).
// Emit only the static Mock() entry point so the call site still binds and the
// TM006 analyzer diagnostic is the single error the user sees, instead of a
// CS1729 pointing into generated code. See issue #6493.
GenerateUnconstructableClassStub(spc, model);
}
else if (model.IsWrapMock)
{
// Wrap mock: generate wrap impl, wrap factory, plus members
Expand Down Expand Up @@ -133,6 +141,15 @@ private static void GenerateSingleTypeMock(SourceProductionContext spc, MockType
}
}

private static void GenerateUnconstructableClassStub(SourceProductionContext spc, MockTypeModel model)
{
var extensionSource = MockStaticExtensionBuilder.BuildForPartialMock(model);
if (!string.IsNullOrEmpty(extensionSource))
{
spc.AddSource($"{GetSafeFileName(model)}_MockStaticExtension.g.cs", extensionSource);
}
}

private static void GenerateDelegateMock(SourceProductionContext spc, MockTypeModel model)
{
var fileName = GetSafeFileName(model);
Expand Down
12 changes: 12 additions & 0 deletions src/TUnit.Mocks.SourceGenerator/Models/MockTypeModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ internal sealed record MockTypeModel : IEquatable<MockTypeModel>
/// <summary>The C# visibility keyword to emit on generated wrapper/extension types.</summary>
public string Visibility => IsPublic ? "public" : "internal";

/// <summary>
/// True for a class target that exposes no constructor the generated impl could chain to.
/// The impl derives from the target, so with every constructor private (or cross-assembly
/// internal) it cannot be subclassed at all — emitting the impl would produce a bare CS1729
/// on generated code. Generation is skipped and TM006 is reported at the call site instead.
/// <see cref="Constructors"/> is populated with exactly the accessible constructors, and only
/// for class targets, so an empty array on a class model means "none accessible".
/// See issue #6493.
/// </summary>
public bool LacksAccessibleConstructor
=> IsPartialMock && !IsInterface && !IsDelegateType && Constructors.Length == 0;

public bool Equals(MockTypeModel? other)
{
if (other is null) return false;
Expand Down
Loading
Loading