diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000000..f50bb1fe21
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000000..72fe00128f
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,12 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+AOTSG0001 | MSTest.AotReflection | Warning | StaticTestClass
+AOTSG0002 | MSTest.AotReflection | Warning | GenericTestClass
+AOTSG0003 | MSTest.AotReflection | Warning | InaccessibleTestClass
+AOTSG0004 | MSTest.AotReflection | Warning | GenericTestMethod
+AOTSG0005 | MSTest.AotReflection | Warning | ByRefParameter
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs
new file mode 100644
index 0000000000..381d55cc40
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+
+using Microsoft.CodeAnalysis;
+
+namespace MSTest.AotReflection.SourceGeneration.Diagnostics;
+
+///
+/// Catalogue of values surfaced by the AOT reflection
+/// source generator when it encounters a [TestClass] shape it cannot materialize.
+/// Each id is registered in AnalyzerReleases.Unshipped.md .
+///
+internal static class DiagnosticDescriptors
+{
+ private const string Category = "MSTest.AotReflection";
+
+ public static readonly DiagnosticDescriptor StaticTestClass = new(
+ id: "AOTSG0001",
+ title: "Test class is static",
+ messageFormat: "[TestClass] type '{0}' is static and cannot be instantiated by the generated registry",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Static test classes cannot be instantiated by the generated registry. Remove the 'static' modifier or use a non-static container.");
+
+ public static readonly DiagnosticDescriptor GenericTestClass = new(
+ id: "AOTSG0002",
+ title: "Test class is generic",
+ messageFormat: "[TestClass] type '{0}' has unbound type parameters (either directly or via a generic outer type) and cannot be materialized as a closed type",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Open generic test classes (or test classes nested inside a generic outer type) have no closed type at generation time. Use a concrete subclass that closes every type parameter.");
+
+ public static readonly DiagnosticDescriptor InaccessibleTestClass = new(
+ id: "AOTSG0003",
+ title: "Test class is not accessible from generated code",
+ messageFormat: "[TestClass] type '{0}' is not reachable from generated code in the same assembly (file-local, or nested in a non-public/non-internal outer type)",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Generated registry code lives in the same assembly but in a different file/type and therefore cannot reference file-local types or types nested in a private, protected, or private-protected outer type. Make the test class — and every enclosing type — at least internal.");
+
+ public static readonly DiagnosticDescriptor GenericTestMethod = new(
+ id: "AOTSG0004",
+ title: "Test method is generic",
+ messageFormat: "Method '{0}.{1}' has type parameters which are not knowable at compile time; the source-generated invoker will skip it",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Generic test methods would need a concrete type-argument list at the invocation site. Replace the method with one or more non-generic specializations.");
+
+ public static readonly DiagnosticDescriptor ByRefParameter = new(
+ id: "AOTSG0005",
+ title: "Parameter uses a by-ref kind",
+ messageFormat: "Parameter '{2}' of '{0}.{1}' is declared with 'ref', 'in', or 'out' and cannot be passed through the 'object?[]' invoker; the member will be skipped",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "By-ref parameters cannot flow through the 'Func' invoker shape. Drop the ref/in/out modifier or move the dependency out of the test signature.");
+
+ private static readonly Dictionary ById = new(StringComparer.Ordinal)
+ {
+ [StaticTestClass.Id] = StaticTestClass,
+ [GenericTestClass.Id] = GenericTestClass,
+ [InaccessibleTestClass.Id] = InaccessibleTestClass,
+ [GenericTestMethod.Id] = GenericTestMethod,
+ [ByRefParameter.Id] = ByRefParameter,
+ };
+
+ public static DiagnosticDescriptor GetById(string id)
+ => ById.TryGetValue(id, out DiagnosticDescriptor? descriptor)
+ ? descriptor
+ : throw new InvalidOperationException($"Unknown diagnostic id '{id}'.");
+}
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs
new file mode 100644
index 0000000000..06eda08594
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+
+using MSTest.AotReflection.SourceGeneration.Model;
+
+namespace MSTest.AotReflection.SourceGeneration.Diagnostics;
+
+///
+/// Equatable payload that travels through the incremental-generator pipeline and is
+/// reified into a real only at the RegisterSourceOutput
+/// stage. Holding only the descriptor id (rather than the descriptor itself) keeps the
+/// record cheaply equatable across runs.
+///
+internal sealed record DiagnosticInfo(
+ string DescriptorId,
+ LocationInfo? Location,
+ EquatableArray MessageArgs)
+{
+ public Diagnostic ToDiagnostic()
+ {
+ DiagnosticDescriptor descriptor = DiagnosticDescriptors.GetById(DescriptorId);
+ ImmutableArray args = MessageArgs.AsImmutableArray();
+ object?[] formattedArgs = new object?[args.Length];
+ for (int i = 0; i < args.Length; i++)
+ {
+ formattedArgs[i] = args[i];
+ }
+
+ return Diagnostic.Create(descriptor, Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None, formattedArgs);
+ }
+
+ public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, LocationInfo? location, params string[] messageArgs)
+ => new(descriptor.Id, location, new EquatableArray(ImmutableArray.Create(messageArgs)));
+}
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs
new file mode 100644
index 0000000000..b521ab17fd
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace MSTest.AotReflection.SourceGeneration.Diagnostics;
+
+///
+/// Value-equatable surrogate for so a reported
+/// can flow through incremental-generator pipelines
+/// without breaking the model-equality contract that gates step caching.
+///
+internal sealed record LocationInfo(string FilePath, TextSpan SourceSpan, LinePositionSpan LineSpan)
+{
+ public Location ToLocation()
+ => Location.Create(FilePath, SourceSpan, LineSpan);
+
+ public static LocationInfo? CreateFrom(SyntaxNode node)
+ => CreateFrom(node.GetLocation());
+
+ public static LocationInfo? CreateFrom(ISymbol symbol)
+ {
+ foreach (SyntaxReference reference in symbol.DeclaringSyntaxReferences)
+ {
+ // The first declaration is the canonical one; partial declarations get the first
+ // physical occurrence which is good enough for diagnostic placement.
+ return CreateFrom(reference.GetSyntax().GetLocation());
+ }
+
+ return null;
+ }
+
+ public static LocationInfo? CreateFrom(Location location)
+ {
+ if (location.SourceTree is null)
+ {
+ return null;
+ }
+
+ return new LocationInfo(
+ location.SourceTree.FilePath,
+ location.SourceSpan,
+ location.GetLineSpan().Span);
+ }
+}
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs
index c0baa4025b..4267dbe718 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
@@ -10,6 +11,7 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
+using MSTest.AotReflection.SourceGeneration.Diagnostics;
using MSTest.AotReflection.SourceGeneration.Helpers;
using MSTest.AotReflection.SourceGeneration.Model;
@@ -35,15 +37,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
"MSTestReflectionMetadata.SupportTypes.g.cs",
SourceText.From(MetadataRegistryEmitter.EmitSupportTypes(), Encoding.UTF8)));
- IncrementalValuesProvider testClasses = context.SyntaxProvider
+ IncrementalValuesProvider rawResults = context.SyntaxProvider
.ForAttributeWithMetadataName(
MSTestAttributeNames.TestClass,
- predicate: static (node, _) =>
- node is TypeDeclarationSyntax type
- && !type.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)),
- transform: static (ctx, ct) => BuildModel(ctx, ct))
- .Where(static model => model is not null)
- .Select(static (model, _) => model!);
+ // Predicate stays cheap and shape-only. Diagnostics for unsupported shapes
+ // (static, generic, inaccessible, generic method, by-ref parameter) are
+ // computed in BuildModel where we have the full ISymbol.
+ predicate: static (node, _) => node is TypeDeclarationSyntax,
+ transform: static (ctx, ct) => BuildResult(ctx, ct));
+
+ // Surface every collected DiagnosticInfo as a real Diagnostic. Empty arrays produce
+ // no work, so this branch is allocation-free for clean compilations.
+ IncrementalValuesProvider diagnostics = rawResults
+ .SelectMany(static (result, _) => result.Diagnostics.AsImmutableArray());
+
+ context.RegisterSourceOutput(diagnostics, static (ctx, info) =>
+ ctx.ReportDiagnostic(info.ToDiagnostic()));
+
+ IncrementalValuesProvider testClasses = rawResults
+ .Where(static result => result.Model is not null)
+ .Select(static (result, _) => result.Model!);
// Pull assembly-level attributes from the compilation (one value per run) and
// wrap them in an equatable model so this branch of the pipeline can stay cached
@@ -70,21 +83,108 @@ node is TypeDeclarationSyntax type
});
}
- private static TestClassModel? BuildModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken)
+ private static TestClassTransformResult BuildResult(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (context.TargetSymbol is not INamedTypeSymbol typeSymbol)
{
- return null;
+ return TestClassTransformResult.Empty;
+ }
+
+ var diagnostics = new List();
+ LocationInfo? classLocation = LocationInfo.CreateFrom(context.TargetNode);
+
+ // Diagnostics that imply we cannot emit ANY model for this class. Reported in
+ // priority order — only the first matching reason is recorded so users aren't
+ // spammed with overlapping warnings (e.g. a static class is also "abstract" at the
+ // IL level, but AOTSG0001 is the only one that's actionable).
+ string fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ if (typeSymbol.IsStatic)
+ {
+ diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.StaticTestClass, classLocation, fqn));
+ return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics));
}
- // Skip abstract / static / generic classes for this PoC — they need extra wiring.
- if (typeSymbol.IsAbstract || typeSymbol.IsStatic || typeSymbol.IsGenericType)
+ if (IsGenericOrInsideGeneric(typeSymbol))
{
- return null;
+ diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.GenericTestClass, classLocation, fqn));
+ return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics));
}
- return TestClassModelBuilder.Build(typeSymbol);
+ if (!IsReachableFromGeneratedCode(typeSymbol))
+ {
+ diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.InaccessibleTestClass, classLocation, fqn));
+ return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics));
+ }
+
+ // Abstract test classes stay silently filtered for now — they're a legitimate
+ // base-class pattern and the right UX needs the concrete-derived discovery from a
+ // future PR.
+ if (typeSymbol.IsAbstract)
+ {
+ return TestClassTransformResult.Empty;
+ }
+
+ TestClassModel model = TestClassModelBuilder.Build(typeSymbol, diagnostics);
+ return new TestClassTransformResult(model, ToEquatable(diagnostics));
+ }
+
+ private static EquatableArray ToEquatable(List diagnostics)
+ => diagnostics.Count == 0
+ ? EquatableArray.Empty
+ : new EquatableArray(diagnostics.ToImmutableArray());
+
+ private static bool IsGenericOrInsideGeneric(INamedTypeSymbol type)
+ {
+ for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType)
+ {
+ if (current.IsGenericType)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type)
+ {
+ // The generated registry lives in the same assembly but in a different file/type,
+ // so it can reach Public / Internal / ProtectedOrInternal types (the latter being
+ // "protected internal" — visible from anywhere in the same assembly). Private,
+ // Protected (alone), and ProtectedAndInternal ("private protected") containing
+ // types make the type unreachable.
+ if (type.IsFileLocal)
+ {
+ return false;
+ }
+
+ for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType)
+ {
+ if (current.IsFileLocal)
+ {
+ return false;
+ }
+
+ switch (current.DeclaredAccessibility)
+ {
+ case Accessibility.Public:
+ case Accessibility.Internal:
+ case Accessibility.ProtectedOrInternal:
+ case Accessibility.NotApplicable:
+ continue;
+ default:
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private sealed record TestClassTransformResult(TestClassModel? Model, EquatableArray Diagnostics)
+ {
+ public static readonly TestClassTransformResult Empty = new(null, EquatableArray.Empty);
}
}
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs
index 50ad1b1703..efead40f59 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs
@@ -32,6 +32,7 @@ public static string EmitSupportTypes()
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
+ sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine();
using (sb.Block($"namespace {GeneratedNamespace}"))
@@ -59,8 +60,8 @@ public static string EmitSupportTypes()
sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();");
sb.AppendLine("/// Materialized argument tuples from [DataRow] attributes (empty for non-data-driven tests). Each object?[] corresponds to one [DataRow] application. ");
sb.AppendLine("public IReadOnlyList DataRows { get; init; } = Array.Empty();");
- sb.AppendLine("/// Direct invoker — replaces . ");
- sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => null;");
+ sb.AppendLine("/// Direct invoker — replaces . Always returns a non-null so the caller can await regardless of whether the underlying test method is void , Task , Task<T> , ValueTask , or ValueTask<T> ; the result value (if any) is discarded. ");
+ sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;");
}
sb.AppendLine();
@@ -92,6 +93,7 @@ public static string EmitRegistry(string assemblyName, AssemblyMetadataModel ass
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
+ sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine();
using (sb.Block($"namespace {GeneratedNamespace}"))
@@ -277,9 +279,35 @@ private static void EmitMethodInvoker(IndentedStringBuilder sb, string classFqn,
string args = BuildArgumentsFromObjectArray(method.Parameters);
string call = $"{target}.{method.Name}({args})";
- string body = method.ReturnsVoid
- ? $"{{ {call}; return null; }}"
- : $"{{ return {call}; }}";
+ // The contract is: return a non-null Task representing the (async or sync) completion of the
+ // test method, discarding any result value. This lets the caller use a single `await invoker(...)`
+ // path regardless of the underlying return shape.
+ // - void / non-Task sync: invoke, return Task.CompletedTask.
+ // - Task / Task: forward the returned Task (treat a `null` return as success).
+ // - ValueTask / ValueTask: avoid Task allocation for the synchronously-completed fast path
+ // via IsCompletedSuccessfully, otherwise call AsTask().
+ string body;
+ if (method.ReturnsTask)
+ {
+ // Task derives from Task, so the same forwarding code handles both. A test method that
+ // *declares* a Task return type and then returns `null` is broken at runtime, but mirroring
+ // reflection-Invoke we tolerate it and treat it as already-completed.
+ body = $"{{ Task? __t = {call}; return __t ?? Task.CompletedTask; }}";
+ }
+ else if (method.ReturnsValueTask)
+ {
+ body = $"{{ var __vt = {call}; return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); }}";
+ }
+ else if (method.ReturnsVoid)
+ {
+ body = $"{{ {call}; return Task.CompletedTask; }}";
+ }
+ else
+ {
+ // Non-void, non-Task return (e.g. `int Test()`). The test runner discards the value; we still
+ // execute the call for its side effects and report success.
+ body = $"{{ _ = {call}; return Task.CompletedTask; }}";
+ }
sb.AppendLine($"Invoke = static (instance, args) => {body},");
}
@@ -321,7 +349,6 @@ private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray parameters)
{
if (parameters.Length == 0)
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs
index 26a4f4eea2..26fecd33f9 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs
@@ -9,6 +9,7 @@
using Microsoft.CodeAnalysis;
+using MSTest.AotReflection.SourceGeneration.Diagnostics;
using MSTest.AotReflection.SourceGeneration.Helpers;
using MSTest.AotReflection.SourceGeneration.Model;
@@ -24,7 +25,7 @@ internal static class TestClassModelBuilder
SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(
SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
- public static TestClassModel Build(INamedTypeSymbol typeSymbol)
+ public static TestClassModel Build(INamedTypeSymbol typeSymbol, List diagnostics)
{
// Methods / properties are walked across the full inheritance chain (excluding
// System.Object) so that MSTest members declared on a base class —
@@ -41,6 +42,8 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol)
ImmutableArray.Builder properties = ImmutableArray.CreateBuilder();
ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder();
+ string leafFqn = typeSymbol.ToDisplayString(FullyQualifiedFormat);
+
for (INamedTypeSymbol? current = typeSymbol;
current is not null && current.SpecialType != SpecialType.System_Object;
current = current.BaseType)
@@ -53,6 +56,13 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol)
{
case IMethodSymbol { MethodKind: MethodKind.Ordinary } method
when IsAccessibleFromConsumer(method):
+ if (TryReportUnsupportedMethod(method, leafFqn, diagnostics))
+ {
+ // Skip generic / by-ref methods entirely so the emitter does not produce
+ // code that references unbound type parameters or ref/in/out arguments.
+ break;
+ }
+
string key = BuildMethodSignatureKey(method);
if (!methodsByKey.ContainsKey(key))
{
@@ -74,6 +84,11 @@ when IsAccessibleFromConsumer(property):
break;
case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor
when isLeaf && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal:
+ if (TryReportUnsupportedMethod(ctor, leafFqn, diagnostics))
+ {
+ break;
+ }
+
ctors.Add(new TestConstructorModel(BuildParameters(ctor)));
break;
}
@@ -81,7 +96,7 @@ when IsAccessibleFromConsumer(property):
}
return new TestClassModel(
- FullyQualifiedTypeName: typeSymbol.ToDisplayString(FullyQualifiedFormat),
+ FullyQualifiedTypeName: leafFqn,
ContainingNamespace: typeSymbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: typeSymbol.ContainingNamespace.ToDisplayString(),
@@ -94,6 +109,41 @@ when IsAccessibleFromConsumer(property):
Attributes: BuildAttributes(typeSymbol.GetAttributes()));
}
+ // Reports AOTSG0004 (generic method) and AOTSG0005 (by-ref parameter) when applicable.
+ // Returns true if the member must be excluded from the emitted model.
+ private static bool TryReportUnsupportedMethod(IMethodSymbol method, string owningClassFqn, List diagnostics)
+ {
+ bool unsupported = false;
+
+ // AOTSG0004 only applies to ordinary methods. Constructors cannot be generic so
+ // method.IsGenericMethod is false for them.
+ if (method.IsGenericMethod)
+ {
+ diagnostics.Add(DiagnosticInfo.Create(
+ DiagnosticDescriptors.GenericTestMethod,
+ LocationInfo.CreateFrom(method),
+ owningClassFqn,
+ method.Name));
+ unsupported = true;
+ }
+
+ foreach (IParameterSymbol parameter in method.Parameters)
+ {
+ if (parameter.RefKind != RefKind.None)
+ {
+ diagnostics.Add(DiagnosticInfo.Create(
+ DiagnosticDescriptors.ByRefParameter,
+ LocationInfo.CreateFrom(parameter),
+ owningClassFqn,
+ method.MethodKind == MethodKind.Constructor ? ".ctor" : method.Name,
+ parameter.Name));
+ unsupported = true;
+ }
+ }
+
+ return unsupported;
+ }
+
// Restricted to accessibilities the emitted helper class (a separate static type
// declared in MSTest.SourceGenerated, not a derived type) can legally call.
// 'protected' and 'private protected' members require the caller to be a derived
@@ -236,10 +286,8 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property)
Attributes: BuildAttributes(CollectInheritedAttributes(property)));
// Mirror the runtime behavior of MemberInfo.GetCustomAttributes(inherit: true): walk the
- // overridden-method chain and union attributes, keeping the most-derived application when
- // the same attribute type appears on multiple levels — but respect
- // [AttributeUsage(Inherited = false)] (the attribute is NOT visible past the level it was
- // declared on) and [AttributeUsage(AllowMultiple = true)] (every occurrence is kept).
+ // overridden-member chain, honor AttributeUsageAttribute.Inherited, and keep only the
+ // most-derived application for attributes that do not allow multiple instances.
private static ImmutableArray CollectInheritedAttributes(IMethodSymbol method)
{
ImmutableArray own = method.GetAttributes();
@@ -250,10 +298,10 @@ private static ImmutableArray CollectInheritedAttributes(IMethodS
var seen = new HashSet(StringComparer.Ordinal);
ImmutableArray.Builder builder = ImmutableArray.CreateBuilder();
- AppendUnique(builder, seen, own, isInheritedLevel: false);
+ AppendAttributes(builder, seen, own, inheritedOnly: false);
for (IMethodSymbol? baseMethod = method.OverriddenMethod; baseMethod is not null; baseMethod = baseMethod.OverriddenMethod)
{
- AppendUnique(builder, seen, baseMethod.GetAttributes(), isInheritedLevel: true);
+ AppendAttributes(builder, seen, baseMethod.GetAttributes(), inheritedOnly: true);
}
return builder.ToImmutable();
@@ -269,20 +317,20 @@ private static ImmutableArray CollectInheritedAttributes(IPropert
var seen = new HashSet(StringComparer.Ordinal);
ImmutableArray.Builder builder = ImmutableArray.CreateBuilder();
- AppendUnique(builder, seen, own, isInheritedLevel: false);
+ AppendAttributes(builder, seen, own, inheritedOnly: false);
for (IPropertySymbol? baseProperty = property.OverriddenProperty; baseProperty is not null; baseProperty = baseProperty.OverriddenProperty)
{
- AppendUnique(builder, seen, baseProperty.GetAttributes(), isInheritedLevel: true);
+ AppendAttributes(builder, seen, baseProperty.GetAttributes(), inheritedOnly: true);
}
return builder.ToImmutable();
}
- private static void AppendUnique(
+ private static void AppendAttributes(
ImmutableArray.Builder builder,
HashSet seen,
ImmutableArray attributes,
- bool isInheritedLevel)
+ bool inheritedOnly)
{
foreach (AttributeData attribute in attributes)
{
@@ -291,66 +339,58 @@ private static void AppendUnique(
continue;
}
- (bool allowMultiple, bool inherited) = GetAttributeUsage(attributeClass);
-
- // A base-level attribute declared with AttributeUsage(Inherited = false) must
- // not leak onto the derived override (matches MemberInfo.GetCustomAttributes(inherit: true)).
- if (isInheritedLevel && !inherited)
+ AttributeUsageMetadata usage = GetAttributeUsage(attributeClass);
+ if (inheritedOnly && !usage.Inherited)
{
continue;
}
- // Attributes declared with AttributeUsage(AllowMultiple = true) may legitimately
- // appear several times across the override chain (e.g. [TestCategory], [DataRow])
- // — keep every instance instead of collapsing them to one.
- if (allowMultiple)
- {
- builder.Add(attribute);
- continue;
- }
-
string key = attributeClass.ToDisplayString(FullyQualifiedFormat);
- if (seen.Add(key))
+ if (usage.AllowMultiple || seen.Add(key))
{
builder.Add(attribute);
}
}
}
- private static (bool AllowMultiple, bool Inherited) GetAttributeUsage(INamedTypeSymbol attributeClass)
+ private static AttributeUsageMetadata GetAttributeUsage(INamedTypeSymbol attributeClass)
{
- for (INamedTypeSymbol? current = attributeClass; current is not null; current = current.BaseType)
+ bool inherited = true;
+ bool allowMultiple = false;
+
+ foreach (AttributeData attribute in attributeClass.GetAttributes())
{
- foreach (AttributeData attribute in current.GetAttributes())
+ if (attribute.AttributeClass?.ToDisplayString(FullyQualifiedFormat) != "global::System.AttributeUsageAttribute")
+ {
+ continue;
+ }
+
+ foreach (KeyValuePair namedArgument in attribute.NamedArguments)
{
- if (attribute.AttributeClass?.ToDisplayString(FullyQualifiedFormat) != "global::System.AttributeUsageAttribute")
+ if (namedArgument.Value.Value is not bool value)
{
continue;
}
- bool allowMultiple = false;
- bool inherited = true;
- foreach (KeyValuePair named in attribute.NamedArguments)
+ switch (namedArgument.Key)
{
- if (named.Key == "AllowMultiple" && named.Value.Value is bool am)
- {
- allowMultiple = am;
- }
- else if (named.Key == "Inherited" && named.Value.Value is bool inh)
- {
- inherited = inh;
- }
+ case nameof(AttributeUsageAttribute.Inherited):
+ inherited = value;
+ break;
+ case nameof(AttributeUsageAttribute.AllowMultiple):
+ allowMultiple = value;
+ break;
}
-
- // [AttributeUsage] on a derived attribute class shadows the base per CLI rules;
- // stop at the first level where it is found.
- return (allowMultiple, inherited);
}
+
+ break;
}
- return (AllowMultiple: false, Inherited: true);
+ return new AttributeUsageMetadata(inherited, allowMultiple);
}
+ private readonly record struct AttributeUsageMetadata(bool Inherited, bool AllowMultiple);
+
private static EquatableArray BuildParameters(IMethodSymbol method)
{
if (method.Parameters.IsDefaultOrEmpty)
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj
index dc9bbade05..e988b20e1f 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj
@@ -29,6 +29,11 @@
+
+
+
+
+
diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs
index cfbe9236b1..0bb379438e 100644
--- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs
+++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs
@@ -36,7 +36,7 @@ public TestMethodAttribute() { }
public string? DisplayName { get; set; }
}
- [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true)]
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class TestCategoryAttribute : System.Attribute
{
public TestCategoryAttribute(string category) { Category = category; }
@@ -116,7 +116,7 @@ public void Test1() { }
registry.Should().Contain("public const string AssemblyName = \"TestSample\";");
registry.Should().Contain("Type = typeof(global::Sample.MyTests)");
registry.Should().Contain("Name = \"Test1\"");
- registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },");
+ registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return Task.CompletedTask; },");
}
[TestMethod]
@@ -158,9 +158,9 @@ public static void Test1() { }
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
- result.Diagnostics.Should().BeEmpty();
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0001");
string registry = GetRegistry(result);
- // Static classes are excluded by the predicate in the generator (cannot be instantiated).
+ // Static classes are excluded from the registry (cannot be instantiated) and reported via AOTSG0001.
registry.Should().NotContain("StaticTests");
}
@@ -216,9 +216,9 @@ public void Test1() { }
GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
- result.Diagnostics.Should().BeEmpty();
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0002");
string registry = GetRegistry(result);
- // Open-generic test classes are out of scope for this PoC.
+ // Open-generic test classes are out of scope for this PoC and reported via AOTSG0002.
registry.Should().NotContain("GenericTests");
}
@@ -403,6 +403,51 @@ public void Sync(int x) { }
"the generated source MUST compile cleanly when consumed in the same compilation as the user code");
}
+ [TestMethod]
+ public void Generator_SkipsProtectedMembers()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class TestContext { }
+
+ [TestClass]
+ public class ProtectedShapes
+ {
+ [TestContext]
+ protected TestContext? Context { get; set; }
+
+ [TestMethod]
+ protected void ProtectedTest() { }
+
+ [TestMethod]
+ private protected void PrivateProtectedTest() { }
+
+ [TestMethod]
+ protected internal void ProtectedInternalTest() { }
+ }
+ }
+ """;
+
+ Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
+ string registry = outputCompilation
+ .SyntaxTrees
+ .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
+ .ToString();
+
+ registry.Should().NotContain("ProtectedTest");
+ registry.Should().NotContain("PrivateProtectedTest");
+ registry.Should().NotContain("Context");
+ registry.Should().Contain("ProtectedInternalTest");
+
+ IEnumerable errors = outputCompilation
+ .GetDiagnostics()
+ .Where(d => d.Severity == DiagnosticSeverity.Error);
+ errors.Should().BeEmpty("the registry can only call members accessible from a non-derived type in the same assembly");
+ }
+
[TestMethod]
public void Generator_StripsNullableAnnotation_FromTypeofExpressions()
{
@@ -571,9 +616,6 @@ public virtual void Run() { }
[TestClass]
public class DerivedTests : BaseTests
{
- // [TestMethod] is re-applied here because the real attribute is declared
- // with AttributeUsage(Inherited = false) and would not be inherited.
- [TestMethod]
public override void Run() { }
}
}
@@ -586,7 +628,43 @@ public override void Run() { }
runEntries.Should().Be(1, "the derived override must replace the base entry (not duplicate it)");
registry.Should().Contain("((global::Sample.DerivedTests)instance!).Run();");
registry.Should().NotContain("((global::Sample.BaseTests)instance!).Run();");
- registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute");
+
+ // TestMethodAttribute is not inherited, so the override should not pick up the base attribute.
+ registry.Should().NotContain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute");
+ }
+
+ [TestMethod]
+ public void Generator_OverriddenVirtualMethod_HonorsInheritedAttributeUsage()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class BaseTests
+ {
+ [TestMethod]
+ [TestCategory("Base")]
+ [DataRow(1)]
+ public virtual void Run(int value) { }
+ }
+
+ [TestClass]
+ public class DerivedTests : BaseTests
+ {
+ [TestMethod]
+ [TestCategory("Derived")]
+ public override void Run(int value) { }
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ registry.Should().Contain("\"Base\"");
+ registry.Should().Contain("\"Derived\"");
+ registry.Should().Contain("DataRows = Array.Empty()");
+ registry.Should().NotContain("new object?[] { 1 }");
}
[TestMethod]
@@ -1032,6 +1110,467 @@ public void Test(string? value) { }
registry.Should().Contain("new object?[] { (object)null! }");
}
+ [TestMethod]
+ public void Generator_SupportType_DeclaresInvokeAsTaskReturning()
+ {
+ const string userCode = """
+ // Empty consumer — we only care about the post-init support types.
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().BeEmpty();
+ string support = result.GeneratedSources
+ .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs")
+ .SourceText.ToString();
+
+ support.Should().Contain("using System.Threading.Tasks;");
+ // Invoke must be Task-returning so consumers can await without type-testing the result.
+ support.Should().Contain("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForVoidMethod_ReturnsCompletedTask()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void SyncVoid() { }
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.Tests)instance!).SyncVoid(); return Task.CompletedTask; },");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForTaskMethod_ForwardsTask()
+ {
+ const string userCode = """
+ using System.Threading.Tasks;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public Task AsyncTask() => Task.CompletedTask;
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ // Task and Task both forward via the same `Task? __t = …` path; null is tolerated so
+ // the invoker contract (non-null Task) holds even for a misbehaving test method.
+ registry.Should().Contain("Invoke = static (instance, args) => { Task? __t = ((global::Sample.Tests)instance!).AsyncTask(); return __t ?? Task.CompletedTask; },");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForTaskOfTMethod_ForwardsTask()
+ {
+ const string userCode = """
+ using System.Threading.Tasks;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public Task AsyncTaskOfInt() => Task.FromResult(42);
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ registry.Should().Contain("Invoke = static (instance, args) => { Task? __t = ((global::Sample.Tests)instance!).AsyncTaskOfInt(); return __t ?? Task.CompletedTask; },");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForValueTaskMethod_UnwrapsViaAsTask()
+ {
+ const string userCode = """
+ using System.Threading.Tasks;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public ValueTask AsyncValueTask() => default;
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ // ValueTask unwrap uses IsCompletedSuccessfully so the synchronous-completion fast path
+ // skips the Task allocation; only when the operation actually went async do we pay AsTask().
+ registry.Should().Contain("Invoke = static (instance, args) => { var __vt = ((global::Sample.Tests)instance!).AsyncValueTask(); return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); },");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask()
+ {
+ const string userCode = """
+ using System.Threading.Tasks;
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public ValueTask AsyncValueTaskOfString() => new ValueTask("ok");
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ registry.Should().Contain("Invoke = static (instance, args) => { var __vt = ((global::Sample.Tests)instance!).AsyncValueTaskOfString(); return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); },");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForNonVoidSyncMethod_DiscardsResultAndReturnsCompletedTask()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public int SyncInt() => 42;
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ // For a sync non-void test the returned value is discarded but the call must still execute
+ // (its side-effects ARE the test). We surface that with a `_ = call;` pattern.
+ registry.Should().Contain("Invoke = static (instance, args) => { _ = ((global::Sample.Tests)instance!).SyncInt(); return Task.CompletedTask; },");
+ }
+
+ [TestMethod]
+ public void Generator_EmittedRegistry_ImportsSystemThreadingTasks()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void Test() { }
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ // The registry file references Task.CompletedTask directly in every invoker, so it must
+ // bring System.Threading.Tasks into scope.
+ registry.Should().Contain("using System.Threading.Tasks;");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0002_ReportedForNestedClassInsideGenericOuter()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class Outer
+ {
+ [TestClass]
+ public class InnerTests
+ {
+ [TestMethod]
+ public void Test1() { }
+ }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0002");
+ string registry = GetRegistry(result);
+ registry.Should().NotContain("InnerTests");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0003_ReportedForFileLocalClass()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample;
+
+ [TestClass]
+ file class FileLocalTests
+ {
+ [TestMethod]
+ public void Test1() { }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003");
+ string registry = GetRegistry(result);
+ registry.Should().NotContain("FileLocalTests");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0003_ReportedForPrivateNestedClass()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class Outer
+ {
+ [TestClass]
+ private class HiddenTests
+ {
+ [TestMethod]
+ public void Test1() { }
+ }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003");
+ string registry = GetRegistry(result);
+ registry.Should().NotContain("HiddenTests");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0003_NotReportedForInternalNestedInPublicOuter()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class Outer
+ {
+ [TestClass]
+ internal class VisibleTests
+ {
+ [TestMethod]
+ public void Test1() { }
+ }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().BeEmpty();
+ string registry = GetRegistry(result);
+ registry.Should().Contain("typeof(global::Sample.Outer.VisibleTests)");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0003_ReportedWhenOuterIsPrivateNested()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class Outer
+ {
+ private class HiddenOuter
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void Test1() { }
+ }
+ }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003");
+ string registry = GetRegistry(result);
+ registry.Should().NotContain("typeof(global::Sample.Outer.HiddenOuter.Tests)");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0004_ReportedForGenericTestMethod_OtherMethodsStillEmitted()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void GenericMethod() { }
+
+ [TestMethod]
+ public void NormalMethod() { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0004");
+ string registry = GetRegistry(result);
+ // The class itself is still emitted because at least one supported member remains.
+ registry.Should().Contain("typeof(global::Sample.Tests)");
+ // The generic method is excluded from the registry; the normal one is present.
+ registry.Should().NotContain("\"GenericMethod\"");
+ registry.Should().Contain("\"NormalMethod\"");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0005_ReportedForByRefParameter()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void RefParam(ref int x) { }
+
+ [TestMethod]
+ public void NormalMethod() { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005");
+ string registry = GetRegistry(result);
+ registry.Should().Contain("typeof(global::Sample.Tests)");
+ registry.Should().NotContain("\"RefParam\"");
+ registry.Should().Contain("\"NormalMethod\"");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0005_ReportedForOutAndInParameters()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void OutParam(out int x) { x = 0; }
+
+ [TestMethod]
+ public void InParam(in int x) { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Where(d => d.Id == "AOTSG0005").Should().HaveCount(2);
+ string registry = GetRegistry(result);
+ registry.Should().NotContain("\"OutParam\"");
+ registry.Should().NotContain("\"InParam\"");
+ }
+
+ [TestMethod]
+ public void Diagnostic_AOTSG0005_ReportedForByRefConstructorParameter()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ public Tests() { }
+
+ public Tests(ref int x) { }
+
+ [TestMethod]
+ public void Test1() { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005");
+ string registry = GetRegistry(result);
+ // The valid parameterless constructor is still emitted.
+ registry.Should().Contain("typeof(global::Sample.Tests)");
+ }
+
+ [TestMethod]
+ public void Diagnostic_NoneReportedForWellFormedTestClass()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void Test1() { }
+
+ [TestMethod]
+ public void Test2(int x, string y) { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().BeEmpty();
+ }
+
[TestMethod]
public void Generator_SkipsProtectedAndPrivateProtectedMembers()
{
@@ -1111,10 +1650,10 @@ public override void Run() { }
}
[TestMethod]
- public void Generator_DistinguishesGenericArity_BetweenSameNamedMethods()
+ public void Generator_ReportsAndSkipsGenericMethods_WithSameName()
{
- // Methods that differ only in generic arity (e.g. M() vs M()) MUST be treated as
- // distinct in the per-class dedup key, otherwise the generator drops one of them.
+ // Generic methods are diagnosed and skipped because the source-generated invoker has
+ // no concrete type-argument list at compile time. The non-generic overload is preserved.
const string userCode = """
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -1135,21 +1674,15 @@ public void Run() { }
}
""";
- Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
- IEnumerable errors = outputCompilation
- .GetDiagnostics()
- .Where(d => d.Severity == DiagnosticSeverity.Error);
- errors.Should().BeEmpty();
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
- string registry = outputCompilation
- .SyntaxTrees
- .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
- .ToString();
+ result.Diagnostics.Where(d => d.Id == "AOTSG0004").Should().HaveCount(2);
+ string registry = GetRegistry(result);
int occurrences = System.Text.RegularExpressions.Regex
.Matches(registry, "Name = \"Run\"")
.Count;
- occurrences.Should().Be(3);
+ occurrences.Should().Be(1);
}
[TestMethod]