Skip to content

Add MSTest reflection source generator (issue #1837)#8586

Open
Evangelink wants to merge 1 commit into
mainfrom
dev/amauryleve/sourcegen-reflection-issue-1837
Open

Add MSTest reflection source generator (issue #1837)#8586
Evangelink wants to merge 1 commit into
mainfrom
dev/amauryleve/sourcegen-reflection-issue-1837

Conversation

@Evangelink
Copy link
Copy Markdown
Member

Fixes part of #1837.

What

Introduces a new MSTestAdapter.PlatformServices.SourceGeneration project that hosts a Roslyn IIncrementalGenerator. The generator discovers [TestClass] types at compile time and emits a [ModuleInitializer] that registers a SourceGeneratedReflectionDataProvider for the user's assembly.

It also adds the runtime infrastructure in MSTestAdapter.PlatformServices to swap the IReflectionOperations and IFileOperations services for source-gen-backed implementations once metadata is registered.

This is the first step toward Native AOT support: when the generator runs, MSTest reads test metadata from compile-time-known data instead of doing reflection at runtime. The feature is opt-in — when no metadata is registered, the platform keeps using the existing reflection-based implementations.

Why

Issue #1837 tracks Native AOT support for MSTest. PR #8263 introduced the IReflectionOperations service abstraction as a prerequisite. This PR delivers the next building block by adding the source generator that will populate that abstraction without reflection.

Scope (MVP)

  • New project MSTestAdapter.PlatformServices.SourceGeneration with ReflectionMetadataGenerator.
  • Emitter producing a per-assembly [ModuleInitializer] that calls ReflectionMetadataHook.SetMetadata.
  • Runtime types: ReflectionMetadataHook, SourceGeneratedReflectionDataProvider, SourceGeneratedReflectionOperations, SourceGeneratedFileOperations, SourceGeneratorToggle.
  • PlatformServiceProvider.SetSourceGeneratedOperations (internal) hook used by ReflectionMetadataHook to swap providers.
  • Unit tests covering: happy path ([TestClass] + [TestMethod]), static / abstract classes being skipped, empty assembly emission.
  • Solution wiring in TestFx.slnx and MSTest.slnf.
  • PublicAPI.Unshipped.txt updated.

Known follow-ups (out of scope for this PR)

These are intentionally deferred to keep this PR reviewable:

  • Populate the rest of the data bag (AssemblyAttributes, TypeAttributes, TypeProperties, TypeMethodAttributes, TypeConstructorsInvoker, TypeMethodLocations). The MVP only emits the assembly name, type list, and per-type method list.
  • [ModuleInitializer] polyfill for netstandard2.0 / older TFM consumers.
  • MSBuild gating (opt-in property like EnableMSTestSourceGeneration).
  • NuGet packaging of the generator into the MSTest analyzer set.
  • Wire up coverage of DataRow / DynamicData / inherited attributes / base-class roll-up.
  • AssemblyInitialize / ClassInitialize / TestInitialize lifecycle.

Validation

I was unable to validate the build locally — my SDK is on a .NET 11 preview that requires an x64 runtime which isn't installed on this machine. I'm relying on CI to confirm. The generator and runtime files were authored against the current IReflectionOperations signature in main, and the diff is purely additive plus the two solution-file lines and one internal method on PlatformServiceProvider.

Notes for reviewers

  • Public API is intentionally minimal: only ReflectionMetadataHook.SetMetadata and the SourceGeneratedReflectionDataProvider shape are public, because the generated module initializer needs to call them.
  • SourceGeneratorToggle uses Interlocked.Exchange to ensure exactly-once swap and to make the toggle race-free if multiple assemblies' module initializers run.
  • ReflectionMetadataGenerator skips static and abstract types — they cannot be discovered as test classes.
  • Happy to split this into smaller PRs (e.g., runtime infra first, then generator) if that's easier to review.

Introduces a new MSTestAdapter.PlatformServices.SourceGeneration project
that hosts an IIncrementalGenerator. It discovers `[TestClass]` types
at compile time and emits a `[ModuleInitializer]` that registers a
`SourceGeneratedReflectionDataProvider` for the user's assembly, plus
the runtime infrastructure in `MSTestAdapter.PlatformServices` to swap
the `IReflectionOperations` and `IFileOperations` services for
source-gen-backed implementations.

This is the first step toward Native AOT support: when the generator
runs, MSTest reads test metadata from compile-time-known data instead of
doing reflection at runtime.

* New project `MSTestAdapter.PlatformServices.SourceGeneration` with
  `ReflectionMetadataGenerator` (`IIncrementalGenerator`) and an
  emitter that produces a `[ModuleInitializer]`.
* Runtime hook `ReflectionMetadataHook.SetMetadata` plus
  `SourceGeneratedReflectionDataProvider`,
  `SourceGeneratedReflectionOperations`,
  `SourceGeneratedFileOperations`, `SourceGeneratorToggle`.
* `PlatformServiceProvider.SetSourceGeneratedOperations` (internal)
  to swap the cached providers.
* Unit tests covering happy path, static/abstract skipping, and empty
  assembly emission.
* Solution wiring in TestFx.slnx and MSTest.slnf.
* PublicAPI.Unshipped.txt updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 25, 2026 22:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an opt-in MSTest reflection metadata source generator (MSTestAdapter.PlatformServices.SourceGeneration) plus new runtime infrastructure in MSTestAdapter.PlatformServices to swap IReflectionOperations/IFileOperations to source-gen-backed implementations via a generated [ModuleInitializer]. This is intended as an early building block toward NativeAOT support (#1837).

Changes:

  • Add a new incremental generator project that discovers [TestClass]/[TestMethod] and emits a module initializer registering metadata.
  • Add runtime “source-generated” reflection/file operations and a public hook (ReflectionMetadataHook) for generated code to register metadata.
  • Add unit tests for the generator and wire new projects into TestFx.slnx and MSTest.slnf, plus update PublicAPI.Unshipped.txt.
Show a summary per file
File Description
TestFx.slnx Adds the new generator project + its unit test project to the full solution.
MSTest.slnf Adds the new generator project + its unit test project to the MSTest solution filter.
src/Adapter/MSTestAdapter.PlatformServices/PlatformServiceProvider.cs Adds internal hook to swap IReflectionOperations/IFileOperations.
src/Adapter/MSTestAdapter.PlatformServices/PublicAPI/PublicAPI.Unshipped.txt Declares new public API surface (ReflectionMetadataHook, SourceGeneratedReflectionDataProvider).
src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/*.cs Adds runtime hook + source-gen-backed reflection/file operations + toggle.
src/Adapter/MSTestAdapter.PlatformServices.SourceGeneration/* Adds generator models/helpers + incremental generator + emitter + banned symbols.
test/UnitTests/MSTestAdapter.PlatformServices.SourceGeneration.UnitTests/* Adds generator unit tests + test runner program + test project.

Copilot's findings

  • Files reviewed: 19/19 changed files
  • Comments generated: 14

Comment on lines +53 to +57
public ConstructorInfo[] GetDeclaredConstructors(Type classType)
=> _dataProvider.TypeConstructors.TryGetValue(classType, out ConstructorInfo[]? constructors)
? constructors
: [];

Comment on lines +58 to +62
public MethodInfo[] GetDeclaredMethods(Type classType)
=> _dataProvider.TypeMethods.TryGetValue(classType, out MethodInfo[]? methods)
? methods
: [];

Comment on lines +63 to +67
public PropertyInfo[] GetDeclaredProperties(Type type)
=> _dataProvider.TypeProperties.TryGetValue(type, out PropertyInfo[]? properties)
? properties
: [];

Comment on lines +39 to +51
public object[] GetCustomAttributes(Assembly assembly, Type type)
{
var matches = new List<object>();
foreach (object attribute in _dataProvider.AssemblyAttributes)
{
if (type.IsInstanceOfType(attribute))
{
matches.Add(attribute);
}
}

return [.. matches];
}
Comment on lines +143 to +146
if (!_dataProvider.TypeConstructorsInvoker.TryGetValue(type, out SourceGeneratedReflectionDataProvider.ConstructorInvoker[]? invokers))
{
throw new InvalidOperationException($"No source-generated constructor invoker registered for type '{type.FullName}'.");
}
Comment on lines +108 to +111
foreach (TestMethodMetadata method in cls.Methods)
{
Append(sb, $" typeof({cls.FullyQualifiedName}).GetMethod({ToCSharpLiteral(method.Name)})!,");
}
Comment on lines +11 to +35
internal static class SourceGeneratorToggle
{
private static bool s_useSourceGenerator;

public static bool UseSourceGenerator
{
get => s_useSourceGenerator;
set
{
if (!value)
{
throw new InvalidOperationException(
$"{nameof(UseSourceGenerator)} can only be set to true. The toggle is intended to be flipped once from the source-generated initialization hook.");
}

if (s_useSourceGenerator)
{
throw new InvalidOperationException(
$"{nameof(UseSourceGenerator)} was already set. The toggle can only be set once from the source-generated initialization hook.");
}

s_useSourceGenerator = true;
}
}
}
Comment on lines +32 to +38
SourceGeneratorToggle.UseSourceGenerator = true;

var reflectionOperations = new SourceGeneratedReflectionOperations(metadata);
var fileOperations = new SourceGeneratedFileOperations(metadata);

PlatformServiceProvider.Instance.SetSourceGeneratedOperations(reflectionOperations, fileOperations);
}
Comment on lines +176 to +180
/// <summary>
/// Swaps the cached <see cref="ReflectionOperations"/> and <see cref="FileOperations"/>
/// instances for the source-generator-backed implementations. Used by
/// <see cref="PlatformServices.SourceGeneration.ReflectionMetadataHook"/>.
/// </summary>
Comment on lines +127 to +142
var references = new[]
{
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(System.Runtime.CompilerServices.ModuleInitializerAttribute).Assembly.Location),
};

var compilation = CSharpCompilation.Create(
"TestSample",
trees,
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

GeneratorDriver driver = CSharpGeneratorDriver.Create(new ReflectionMetadataGenerator());
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _);

return driver.GetRunResult().Results[0];
Comment on lines +42 to +48
foreach (object attribute in _dataProvider.AssemblyAttributes)
{
if (type.IsInstanceOfType(attribute))
{
matches.Add(attribute);
}
}
Comment on lines +193 to +199
foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider))
{
if (attribute is TAttribute)
{
return true;
}
}
Comment on lines +207 to +213
foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider))
{
if (attribute is TAttribute typed)
{
return typed;
}
}
Comment on lines +222 to +233
foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider))
{
if (attribute is TAttribute typed)
{
if (found is not null)
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Found multiple attributes of type '{0}' when only one was expected.", typeof(TAttribute)));
}

found = typed;
}
}
Comment on lines +241 to +247
foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider))
{
if (attribute is TAttributeType typed)
{
yield return typed;
}
}
Comment on lines +87 to +100
foreach (AttributeData attribute in method.GetAttributes())
{
INamedTypeSymbol? attributeClass = attribute.AttributeClass;
while (attributeClass is not null)
{
if (attributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
== "global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute")
{
return true;
}

attributeClass = attributeClass.BaseType;
}
}

if (!includeNonPublic)
{
bool isPublic = property.GetMethod?.IsPublic == true || property.SetMethod?.IsPublic == true;

if (!includeNonPublic)
{
bool isPublic = property.GetMethod?.IsPublic == true || property.SetMethod?.IsPublic == true;
@Evangelink
Copy link
Copy Markdown
Member Author

🔍 Build Failure Analysis

Summary — The build failed due to a missing interface method and enforced code style violations across multiple target frameworks.

Root cause 1: Missing interface method SetSourceGeneratedOperations

The code in ReflectionMetadataHook.cs calls SetSourceGeneratedOperations on an IPlatformServiceProvider interface reference, but this method is not declared in the interface — it only exists as an internal method on the concrete PlatformServiceProvider class (line 181).

Affected files / errors

Proposed fix

Cast PlatformServiceProvider.Instance to the concrete type before calling the internal method:

-        PlatformServiceProvider.Instance.SetSourceGeneratedOperations(reflectionOperations, fileOperations);
+        ((PlatformServiceProvider)PlatformServiceProvider.Instance).SetSourceGeneratedOperations(reflectionOperations, fileOperations);

Alternative fix (if you want to expose this as part of the interface contract):

Add the method to IPlatformServiceProvider.cs:

     ITestContext GetTestContext(ITestMethod? testMethod, string? testClassFullName, IDictionary<string, object?> properties, IMessageLogger messageLogger, UTF.UnitTestOutcome outcome);
+
+    /// <summary>
+    /// Swaps the cached reflection and file operations with source-generated implementations.
+    /// </summary>
+    void SetSourceGeneratedOperations(IReflectionOperations reflectionOperations, IFileOperations fileOperations);
 }

And change the PlatformServiceProvider implementation from internal to public.


Root cause 2: Code style violations (IDE0032 — Use auto property)

Four fields are declared with explicit backing fields but could be converted to auto-properties. The analyzer rule IDE0032 is being enforced as an error.

Affected files / errors

Proposed fix

Convert explicit backing fields to auto-properties. These are readonly fields that are only assigned once in the constructor, so they can use the newer auto-property syntax with initializers.


Root cause 3: Code style violations (IDE0046 — Simplify conditional expression)

Three locations have if statements that can be simplified to conditional expressions. The analyzer rule IDE0046 is being enforced as an error.

Affected files / errors

Proposed fix

Simplify if/return patterns to ternary expressions or null-coalescing operators where appropriate.


Build overview
  • Project: MSTestAdapter.PlatformServices.csproj
  • Target frameworks: net8.0, net9.0 (errors occur in both)
  • Configuration: Debug
  • Exit code: 1 (failure)
  • Total unique errors: 7 distinct issues (multiplied across TFMs = 14 total occurrences)

The build failed during the CoreCompile target when compiling the newly added SourceGeneration code.

All MSBuild errors (14 occurrences)
Code Project File:Line Message
CS1061 MSTestAdapter.PlatformServices ReflectionMetadataHook.cs:37 'IPlatformServiceProvider' does not contain a definition for 'SetSourceGeneratedOperations'... (net8.0)
CS1061 MSTestAdapter.PlatformServices ReflectionMetadataHook.cs:37 'IPlatformServiceProvider' does not contain a definition for 'SetSourceGeneratedOperations'... (net9.0)
IDE0032 MSTestAdapter.PlatformServices SourceGeneratorToggle.cs:13 Use auto property (net8.0)
IDE0032 MSTestAdapter.PlatformServices SourceGeneratorToggle.cs:13 Use auto property (net9.0)
IDE0032 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:15 Use auto property (net8.0)
IDE0032 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:15 Use auto property (net9.0)
IDE0032 MSTestAdapter.PlatformServices SourceGeneratedFileOperations.cs:16 Use auto property (net8.0)
IDE0032 MSTestAdapter.PlatformServices SourceGeneratedFileOperations.cs:16 Use auto property (net9.0)
IDE0046 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:26 'if' statement can be simplified (net8.0)
IDE0046 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:26 'if' statement can be simplified (net9.0)
IDE0046 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:257 'if' statement can be simplified (net8.0)
IDE0046 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:257 'if' statement can be simplified (net9.0)
IDE0046 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:291 'if' statement can be simplified (net8.0)
IDE0046 MSTestAdapter.PlatformServices SourceGeneratedReflectionOperations.cs:291 'if' statement can be simplified (net9.0)

🤖 Generated by the Build Failure Analysis workflow · commit 6887de2

Generated by Build Failure Analysis for issue #8586 · ● 1.3M ·

Copy link
Copy Markdown
Member Author

@Evangelink Evangelink left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by Build Failure Analysis for issue #8586 · ● 1.3M

var reflectionOperations = new SourceGeneratedReflectionOperations(metadata);
var fileOperations = new SourceGeneratedFileOperations(metadata);

PlatformServiceProvider.Instance.SetSourceGeneratedOperations(reflectionOperations, fileOperations);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 CS1061SetSourceGeneratedOperations is not part of the IPlatformServiceProvider interface

The method exists on the concrete PlatformServiceProvider class but not on the interface. Cast to the concrete type to access the internal method:

Suggested change
PlatformServiceProvider.Instance.SetSourceGeneratedOperations(reflectionOperations, fileOperations);
((PlatformServiceProvider)PlatformServiceProvider.Instance).SetSourceGeneratedOperations(reflectionOperations, fileOperations);

return [];
}

if (!_dataProvider.TypeMethodAttributes.TryGetValue(method.DeclaringType, out Dictionary<string, Attribute[]>? methodAttributes))
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0046 — Simplify if statement chain

The dictionary lookup pattern can be simplified:

Suggested change
if (!_dataProvider.TypeMethodAttributes.TryGetValue(method.DeclaringType, out Dictionary<string, Attribute[]>? methodAttributes))
return method.DeclaringType is null || !_dataProvider.TypeMethodAttributes.TryGetValue(method.DeclaringType, out Dictionary<string, Attribute[]>? methodAttributes)
? []
: methodAttributes.TryGetValue(method.Name, out Attribute[]? attributes) ? attributes : [];

/// </summary>
internal static class SourceGeneratorToggle
{
private static bool s_useSourceGenerator;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0032 — Use auto property instead of explicit backing field

The backing field s_useSourceGenerator can be replaced with an auto-property using the field keyword:

Suggested change
private static bool s_useSourceGenerator;
public static bool UseSourceGenerator

[return: NotNullIfNotNull(nameof(memberInfo))]
public object[]? GetCustomAttributes(MemberInfo memberInfo)
{
if (memberInfo is null)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0046 — Simplify if statement to conditional expression

The null check can be simplified:

Suggested change
if (memberInfo is null)
return memberInfo is null ? null : GetAttributes(memberInfo);

throw new ArgumentNullException(nameof(attributeProvider));
}

if (attributeProvider is not (MemberInfo or Assembly))
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0046 — Simplify nested if statements

The nested pattern matching check can be combined with early returns:

Suggested change
if (attributeProvider is not (MemberInfo or Assembly))
return attributeProvider is not (MemberInfo or Assembly)
? throw new ArgumentException(
$"Unsupported attribute provider type: {attributeProvider.GetType()}. Only MemberInfo and Assembly are supported.",
nameof(attributeProvider))
: _attributeCache.GetOrAdd(attributeProvider, GetAttributes);

/// </summary>
internal sealed class SourceGeneratedReflectionOperations : IReflectionOperations
{
private readonly SourceGeneratedReflectionDataProvider _dataProvider;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0032 — Use auto property instead of explicit backing field

Replace _dataProvider with the field keyword in the property:

Suggested change
private readonly SourceGeneratedReflectionDataProvider _dataProvider;
public SourceGeneratedReflectionOperations(SourceGeneratedReflectionDataProvider dataProvider)

public SourceGeneratedFileOperations(SourceGeneratedReflectionDataProvider dataProvider)
=> _dataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider));

internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0032 — Replace DataProvider getter to use field keyword

Suggested change
internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider;
internal SourceGeneratedReflectionDataProvider DataProvider => field;

internal sealed class SourceGeneratedFileOperations : IFileOperations
{
private readonly FileOperations _inner = new();
private readonly SourceGeneratedReflectionDataProvider _dataProvider;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0032 — Use auto property instead of explicit backing field

Replace _dataProvider with the field keyword in the property:

Suggested change
private readonly SourceGeneratedReflectionDataProvider _dataProvider;
public SourceGeneratedFileOperations(SourceGeneratedReflectionDataProvider dataProvider)

public SourceGeneratedReflectionOperations(SourceGeneratedReflectionDataProvider dataProvider)
=> _dataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider));

internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0032 — Replace DataProvider getter to use field keyword

Suggested change
internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider;
internal SourceGeneratedReflectionDataProvider DataProvider => field;


public static bool UseSourceGenerator
{
get => s_useSourceGenerator;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 IDE0032 — Replace getter/setter with field keyword

Suggested change
get => s_useSourceGenerator;
get => field;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants