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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MSTest.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"src\\Analyzers\\MSTest.Analyzers.CodeFixes\\MSTest.Analyzers.CodeFixes.csproj",
"src\\Analyzers\\MSTest.Analyzers.Package\\MSTest.Analyzers.Package.csproj",
"src\\Analyzers\\MSTest.Analyzers\\MSTest.Analyzers.csproj",
"src\\Analyzers\\MSTest.AotReflection.SourceGeneration\\MSTest.AotReflection.SourceGeneration.csproj",
"src\\Analyzers\\MSTest.GlobalConfigsGenerator\\MSTest.GlobalConfigsGenerator.csproj",
"src\\Analyzers\\MSTest.SourceGeneration\\MSTest.SourceGeneration.csproj",
"src\\Package\\MSTest.Sdk\\MSTest.Sdk.csproj",
Expand Down
2 changes: 2 additions & 0 deletions TestFx.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
<BuildDependency Project="src/Analyzers/MSTest.GlobalConfigsGenerator/MSTest.GlobalConfigsGenerator.csproj" />
</Project>
<Project Path="src/Analyzers/MSTest.Analyzers/MSTest.Analyzers.csproj" />
<Project Path="src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj" />
<Project Path="src/Analyzers/MSTest.GlobalConfigsGenerator/MSTest.GlobalConfigsGenerator.csproj" />
<Project Path="src/Analyzers/MSTest.SourceGeneration/MSTest.SourceGeneration.csproj" />
</Folder>
Expand Down Expand Up @@ -133,6 +134,7 @@
<Project Path="test/UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests/Microsoft.Testing.Platform.MSBuild.UnitTests.csproj" />
<Project Path="test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj" />
<Project Path="test/UnitTests/MSTest.Analyzers.UnitTests/MSTest.Analyzers.UnitTests.csproj" />
<Project Path="test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj" />
<Project Path="test/UnitTests/MSTest.SelfRealExamples.UnitTests/MSTest.SelfRealExamples.UnitTests.csproj" />
<Project Path="test/UnitTests/MSTest.SourceGeneration.UnitTests/MSTest.SourceGeneration.UnitTests.csproj" />
<Project Path="test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/MSTestAdapter.PlatformServices.UnitTests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Catalogue of <see cref="DiagnosticDescriptor"/> values surfaced by the AOT reflection
/// source generator when it encounters a <c>[TestClass]</c> shape it cannot materialize.
/// Each id is registered in <c>AnalyzerReleases.Unshipped.md</c>.
/// </summary>
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.");
Comment on lines +41 to +45

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<object?, object?[]?, Task>' invoker shape. Drop the ref/in/out modifier or move the dependency out of the test signature.");

private static readonly Dictionary<string, DiagnosticDescriptor> 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}'.");
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Equatable payload that travels through the incremental-generator pipeline and is
/// reified into a real <see cref="Diagnostic" /> only at the <c>RegisterSourceOutput</c>
/// stage. Holding only the descriptor id (rather than the descriptor itself) keeps the
/// record cheaply equatable across runs.
/// </summary>
internal sealed record DiagnosticInfo(
string DescriptorId,
LocationInfo? Location,
EquatableArray<string> MessageArgs)
{
public Diagnostic ToDiagnostic()
{
DiagnosticDescriptor descriptor = DiagnosticDescriptors.GetById(DescriptorId);
ImmutableArray<string> 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<string>(ImmutableArray.Create(messageArgs)));
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Value-equatable surrogate for <see cref="Location" /> so a reported
/// <see cref="DiagnosticInfo" /> can flow through incremental-generator pipelines
/// without breaking the model-equality contract that gates step caching.
/// </summary>
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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -35,43 +37,154 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
"MSTestReflectionMetadata.SupportTypes.g.cs",
SourceText.From(MetadataRegistryEmitter.EmitSupportTypes(), Encoding.UTF8)));

IncrementalValuesProvider<TestClassModel> testClasses = context.SyntaxProvider
IncrementalValuesProvider<TestClassTransformResult> 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!);

IncrementalValueProvider<(string? AssemblyName, ImmutableArray<TestClassModel> Classes)> combined =
// 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<DiagnosticInfo> diagnostics = rawResults
.SelectMany(static (result, _) => result.Diagnostics.AsImmutableArray());

context.RegisterSourceOutput(diagnostics, static (ctx, info) =>
ctx.ReportDiagnostic(info.ToDiagnostic()));

IncrementalValuesProvider<TestClassModel> 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
// when only test-class code changes.
IncrementalValueProvider<AssemblyMetadataModel> assemblyMetadata =
context.CompilationProvider.Select(static (c, ct) =>
{
ct.ThrowIfCancellationRequested();
return new AssemblyMetadataModel(
TestClassModelBuilder.BuildAttributes(c.Assembly.GetAttributes()));
});

IncrementalValueProvider<(string? AssemblyName, AssemblyMetadataModel Metadata, ImmutableArray<TestClassModel> Classes)> combined =
context.CompilationProvider.Select(static (c, _) => c.AssemblyName)
.Combine(testClasses.Collect());
.Combine(assemblyMetadata)
.Combine(testClasses.Collect())
.Select(static (tuple, _) => (tuple.Left.Left, tuple.Left.Right, tuple.Right));

context.RegisterImplementationSourceOutput(combined, static (ctx, payload) =>
{
string assemblyName = payload.AssemblyName ?? "Unknown";
string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Classes);
string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Metadata, payload.Classes);
ctx.AddSource("MSTestReflectionMetadata.Registry.g.cs", SourceText.From(source, Encoding.UTF8));
});
}

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<DiagnosticInfo>();
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));
}

if (IsGenericOrInsideGeneric(typeSymbol))
{
diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.GenericTestClass, classLocation, fqn));
return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics));
}

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<DiagnosticInfo> ToEquatable(List<DiagnosticInfo> diagnostics)
=> diagnostics.Count == 0
? EquatableArray<DiagnosticInfo>.Empty
: new EquatableArray<DiagnosticInfo>(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;
}

// Skip abstract / static / generic classes for this PoC — they need extra wiring.
if (typeSymbol.IsAbstract || typeSymbol.IsStatic || typeSymbol.IsGenericType)
for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType)
{
return null;
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 TestClassModelBuilder.Build(typeSymbol);
return true;
}

private sealed record TestClassTransformResult(TestClassModel? Model, EquatableArray<DiagnosticInfo> Diagnostics)
{
public static readonly TestClassTransformResult Empty = new(null, EquatableArray<DiagnosticInfo>.Empty);
}
}
Loading
Loading