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..e1b0e1ff6c --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,75 @@ +// 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; + +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 private/private-protected 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 (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..9e224190e7 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs @@ -0,0 +1,41 @@ +// 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) + => location.SourceTree is null + ? null + : 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..73cd670254 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -2,14 +2,12 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Immutable; -using System.Linq; -using System.Text; -using System.Threading; using Microsoft.CodeAnalysis; 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 +33,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 BuildResult 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 +79,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(); + var 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..d8a02d5593 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -1,10 +1,6 @@ // 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.Globalization; -using System.Linq; - using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; @@ -32,6 +28,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}")) @@ -39,46 +36,46 @@ public static string EmitSupportTypes() sb.AppendLine("/// Describes one test class as discovered at compile-time. Mirrors what IReflectionOperations would return at runtime."); using (sb.Block("internal sealed class TestClassReflectionInfo")) { - sb.AppendLine("public Type Type { get; init; } = null!;"); - sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); - sb.AppendLine("public IReadOnlyList Methods { get; init; } = Array.Empty();"); - sb.AppendLine("public IReadOnlyList Properties { get; init; } = Array.Empty();"); - sb.AppendLine("public IReadOnlyList Constructors { get; init; } = Array.Empty();"); + sb.AppendLine("public Type Type { get; set; } = null!;"); + sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList Methods { get; set; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList Properties { get; set; } = Array.Empty();"); + sb.AppendLine("public IReadOnlyList Constructors { get; set; } = Array.Empty();"); } sb.AppendLine(); using (sb.Block("internal sealed class TestMethodReflectionInfo")) { - sb.AppendLine("public string Name { get; init; } = string.Empty;"); - sb.AppendLine("public bool IsStatic { get; init; }"); - sb.AppendLine("public bool ReturnsTask { get; init; }"); - sb.AppendLine("public bool ReturnsValueTask { get; init; }"); - sb.AppendLine("public bool ReturnsVoid { get; init; }"); - sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();"); - sb.AppendLine("public string[] ParameterNames { get; init; } = Array.Empty();"); - sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); + sb.AppendLine("public string Name { get; set; } = string.Empty;"); + sb.AppendLine("public bool IsStatic { get; set; }"); + sb.AppendLine("public bool ReturnsTask { get; set; }"); + sb.AppendLine("public bool ReturnsValueTask { get; set; }"); + sb.AppendLine("public bool ReturnsVoid { get; set; }"); + sb.AppendLine("public Type[] ParameterTypes { get; set; } = Array.Empty();"); + sb.AppendLine("public string[] ParameterNames { get; set; } = Array.Empty();"); + sb.AppendLine("public Attribute[] Attributes { get; set; } = 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("public IReadOnlyList DataRows { get; set; } = Array.Empty();"); + 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; set; } = static (_, _) => Task.CompletedTask;"); } sb.AppendLine(); using (sb.Block("internal sealed class TestPropertyReflectionInfo")) { - sb.AppendLine("public string Name { get; init; } = string.Empty;"); - sb.AppendLine("public Type PropertyType { get; init; } = typeof(object);"); - sb.AppendLine("public bool HasPublicSetter { get; init; }"); - sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); - sb.AppendLine("public Func Get { get; init; } = static _ => null;"); - sb.AppendLine("public Action Set { get; init; } = static (_, _) => { };"); + sb.AppendLine("public string Name { get; set; } = string.Empty;"); + sb.AppendLine("public Type PropertyType { get; set; } = typeof(object);"); + sb.AppendLine("public bool HasPublicSetter { get; set; }"); + sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();"); + sb.AppendLine("public Func Get { get; set; } = static _ => null;"); + sb.AppendLine("public Action Set { get; set; } = static (_, _) => { };"); } sb.AppendLine(); using (sb.Block("internal sealed class TestConstructorReflectionInfo")) { - sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();"); - sb.AppendLine("public Func Invoke { get; init; } = static _ => throw new InvalidOperationException(\"No constructor registered.\");"); + sb.AppendLine("public Type[] ParameterTypes { get; set; } = Array.Empty();"); + sb.AppendLine("public Func Invoke { get; set; } = static _ => throw new InvalidOperationException(\"No constructor registered.\");"); } } @@ -92,6 +89,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 +275,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 +345,6 @@ private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray parameters) { if (parameters.Length == 0) @@ -455,15 +478,10 @@ private static string FormatPrimitive(object? value) }; private static string BuildArgumentsFromObjectArray(EquatableArray parameters) - { - if (parameters.Length == 0) - { - return string.Empty; - } - - return string.Join(", ", parameters.AsImmutableArray() - .Select((p, i) => $"({p.FullyQualifiedType})args![{i}]!")); - } + => parameters.Length == 0 + ? string.Empty + : string.Join(", ", parameters.AsImmutableArray() + .Select((p, i) => $"({p.FullyQualifiedType})args![{i}]!")); private static string Bool(bool value) => value ? "true" : "false"; diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 26a4f4eea2..49893bbbe9 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -1,14 +1,11 @@ // 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 System.Collections.Immutable; -using System.Linq; -using System.Text; using Microsoft.CodeAnalysis; +using MSTest.AotReflection.SourceGeneration.Diagnostics; using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; @@ -24,7 +21,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 +38,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 +52,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 +80,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 +92,7 @@ when IsAccessibleFromConsumer(property): } return new TestClassModel( - FullyQualifiedTypeName: typeSymbol.ToDisplayString(FullyQualifiedFormat), + FullyQualifiedTypeName: leafFqn, ContainingNamespace: typeSymbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : typeSymbol.ContainingNamespace.ToDisplayString(), @@ -94,6 +105,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 @@ -358,7 +404,7 @@ private static EquatableArray BuildParameters(IMethodSymbol return EquatableArray.Empty; } - TestParameterModel[] parameters = new TestParameterModel[method.Parameters.Length]; + var parameters = new TestParameterModel[method.Parameters.Length]; for (int i = 0; i < method.Parameters.Length; i++) { IParameterSymbol p = method.Parameters[i]; @@ -370,17 +416,12 @@ private static EquatableArray BuildParameters(IMethodSymbol public static EquatableArray BuildAttributes( ImmutableArray attributes) - { - if (attributes.IsDefaultOrEmpty) - { - return EquatableArray.Empty; - } - - return attributes - .Select(BuildAttribute) - .WhereNotNull() - .ToEquatableArray(); - } + => attributes.IsDefaultOrEmpty + ? EquatableArray.Empty + : attributes + .Select(BuildAttribute) + .WhereNotNull() + .ToEquatableArray(); private static AttributeApplicationModel? BuildAttribute(AttributeData attribute) { @@ -399,34 +440,29 @@ public static EquatableArray BuildAttributes( } private static TypedConstantModel ToModel(TypedConstant constant) - { - if (constant.IsNull) - { - return new TypedConstantModel(ConstantValueKind.Null, constant.Type?.ToDisplayString(FullyQualifiedFormat), null, EquatableArray.Empty); - } - - return constant.Kind switch - { - TypedConstantKind.Array => new TypedConstantModel( - ConstantValueKind.Array, - constant.Type?.ToDisplayString(FullyQualifiedFormat), - null, - constant.Values.Select(ToModel).ToEquatableArray()), - TypedConstantKind.Enum => new TypedConstantModel( - ConstantValueKind.Enum, - constant.Type?.ToDisplayString(FullyQualifiedFormat), - constant.Value, - EquatableArray.Empty), - TypedConstantKind.Type => new TypedConstantModel( - ConstantValueKind.Type, - (constant.Value as ITypeSymbol)?.ToDisplayString(FullyQualifiedFormat), - null, - EquatableArray.Empty), - _ => new TypedConstantModel( - ConstantValueKind.Primitive, - constant.Type?.ToDisplayString(FullyQualifiedFormat), - constant.Value, - EquatableArray.Empty), - }; - } + => constant.IsNull + ? new TypedConstantModel(ConstantValueKind.Null, constant.Type?.ToDisplayString(FullyQualifiedFormat), null, EquatableArray.Empty) + : constant.Kind switch + { + TypedConstantKind.Array => new TypedConstantModel( + ConstantValueKind.Array, + constant.Type?.ToDisplayString(FullyQualifiedFormat), + null, + constant.Values.Select(ToModel).ToEquatableArray()), + TypedConstantKind.Enum => new TypedConstantModel( + ConstantValueKind.Enum, + constant.Type?.ToDisplayString(FullyQualifiedFormat), + constant.Value, + EquatableArray.Empty), + TypedConstantKind.Type => new TypedConstantModel( + ConstantValueKind.Type, + (constant.Value as ITypeSymbol)?.ToDisplayString(FullyQualifiedFormat), + null, + EquatableArray.Empty), + _ => new TypedConstantModel( + ConstantValueKind.Primitive, + constant.Type?.ToDisplayString(FullyQualifiedFormat), + constant.Value, + EquatableArray.Empty), + }; } diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs index 0690bc2db5..6899b7cce4 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs @@ -1,10 +1,6 @@ // 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 System.Text; - namespace MSTest.AotReflection.SourceGeneration.Helpers; /// 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/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs index 0be713a783..4fe48c4ec5 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs @@ -1,10 +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; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; namespace MSTest.AotReflection.SourceGeneration.Model; @@ -90,6 +87,7 @@ internal sealed record TestClassModel( /// Value-equatable wrapper around so incremental generation /// can cache results between runs. Kept minimal — we don't need indexing in this PoC. /// +/// The element type. internal readonly struct EquatableArray : IEquatable> where T : IEquatable { diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index cfbe9236b1..9bba2f4ae8 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -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"); } @@ -518,6 +518,58 @@ public void DerivedTest() { } registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute"); } + [TestMethod] + public void Generator_ExcludesProtectedAndPrivateProtectedMembersFromBaseType() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + public class BaseTests + { + [TestMethod] + protected void ProtectedTest() { } + + [TestMethod] + private protected void PrivateProtectedTest() { } + + [TestMethod] + protected internal void ProtectedInternalTest() { } + + [TestContext] + protected TestContext ProtectedContext { get; set; } = new(); + + [TestContext] + private protected TestContext PrivateProtectedContext { get; set; } = new(); + + [TestContext] + protected internal TestContext ProtectedInternalContext { get; set; } = new(); + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().NotContain("Name = \"ProtectedTest\""); + registry.Should().NotContain("Name = \"PrivateProtectedTest\""); + registry.Should().NotContain("Name = \"ProtectedContext\""); + registry.Should().NotContain("Name = \"PrivateProtectedContext\""); + registry.Should().Contain("Name = \"ProtectedInternalTest\""); + registry.Should().Contain("Name = \"ProtectedInternalContext\""); + } + [TestMethod] public void Generator_IncludesMethodsFromMultiLevelInheritance() { @@ -1111,81 +1163,530 @@ public override void Run() { } } [TestMethod] - public void Generator_DistinguishesGenericArity_BetweenSameNamedMethods() + public void Generator_DoesNotInherit_TestMethodOrDataRow_OntoOverride() + { + // [TestMethod] and [DataRow] are declared with AttributeUsage(Inherited = false) in + // the real MSTest assembly. An override that does NOT re-apply [TestMethod] is not a + // test, and inherited [DataRow]s must not leak from the base method onto the override. + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + [DataRow(1)] + [DataRow(2)] + public virtual void Run(int x) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + // Override deliberately does not re-apply [TestMethod] or any [DataRow]. + public override void Run(int x) { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Neither the [TestMethod] nor the inherited [DataRow]s should propagate onto the override. + registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute()"); + registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("); + registry.Should().NotContain("new DataRowModel("); + } + + [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. + // Uses `set` (not `init`) per the repo guideline that new public-shaped API must not use init accessors. + support.Should().Contain("public Func Invoke { get; set; } = static (_, _) => Task.CompletedTask;"); + } + + [TestMethod] + public void Generator_InvokerForVoidMethod_ReturnsCompletedTask() { - // 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. const string userCode = """ using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Sample { [TestClass] - public class GenericArityTests + public class Tests { [TestMethod] - public void Run() { } + 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 void Run() { } + 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 void Run() { } + public Task AsyncTaskOfInt() => Task.FromResult(42); } } """; - Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); - IEnumerable errors = outputCompilation - .GetDiagnostics() - .Where(d => d.Severity == DiagnosticSeverity.Error); - errors.Should().BeEmpty(); + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); - string registry = outputCompilation - .SyntaxTrees - .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal)) - .ToString(); + 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; - int occurrences = System.Text.RegularExpressions.Regex - .Matches(registry, "Name = \"Run\"") - .Count; - occurrences.Should().Be(3); + 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_DoesNotInherit_TestMethodOrDataRow_OntoOverride() + public void Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask() { - // [TestMethod] and [DataRow] are declared with AttributeUsage(Inherited = false) in - // the real MSTest assembly. An override that does NOT re-apply [TestMethod] is not a - // test, and inherited [DataRow]s must not leak from the base method onto the override. const string userCode = """ + using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Sample { - public class BaseTests + [TestClass] + public class Tests { [TestMethod] - [DataRow(1)] - [DataRow(2)] - public virtual void Run(int x) { } + 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 DerivedTests : BaseTests + public class Tests { - // Override deliberately does not re-apply [TestMethod] or any [DataRow]. - public override void Run(int x) { } + [TestMethod] + public int SyncInt() => 42; } } """; string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); - // Neither the [TestMethod] nor the inherited [DataRow]s should propagate onto the override. - registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute()"); - registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("); - registry.Should().NotContain("new DataRowModel("); + // 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_SupportTypes_DoNotUseInitAccessors() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + // Repo guideline: newly introduced public-shaped API (even when emitted as `internal sealed` + // into the consumer assembly) MUST NOT use `init` accessors. Guard against accidental + // reintroduction. + support.Should().NotContain("{ get; init; }"); } private static string GetRegistry(GeneratorRunResult result)