Add MSTest reflection source generator (issue #1837)#8586
Conversation
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>
There was a problem hiding this comment.
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.slnxandMSTest.slnf, plus updatePublicAPI.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
| public ConstructorInfo[] GetDeclaredConstructors(Type classType) | ||
| => _dataProvider.TypeConstructors.TryGetValue(classType, out ConstructorInfo[]? constructors) | ||
| ? constructors | ||
| : []; | ||
|
|
| public MethodInfo[] GetDeclaredMethods(Type classType) | ||
| => _dataProvider.TypeMethods.TryGetValue(classType, out MethodInfo[]? methods) | ||
| ? methods | ||
| : []; | ||
|
|
| public PropertyInfo[] GetDeclaredProperties(Type type) | ||
| => _dataProvider.TypeProperties.TryGetValue(type, out PropertyInfo[]? properties) | ||
| ? properties | ||
| : []; | ||
|
|
| 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]; | ||
| } |
| if (!_dataProvider.TypeConstructorsInvoker.TryGetValue(type, out SourceGeneratedReflectionDataProvider.ConstructorInvoker[]? invokers)) | ||
| { | ||
| throw new InvalidOperationException($"No source-generated constructor invoker registered for type '{type.FullName}'."); | ||
| } |
| foreach (TestMethodMetadata method in cls.Methods) | ||
| { | ||
| Append(sb, $" typeof({cls.FullyQualifiedName}).GetMethod({ToCSharpLiteral(method.Name)})!,"); | ||
| } |
| 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; | ||
| } | ||
| } | ||
| } |
| SourceGeneratorToggle.UseSourceGenerator = true; | ||
|
|
||
| var reflectionOperations = new SourceGeneratedReflectionOperations(metadata); | ||
| var fileOperations = new SourceGeneratedFileOperations(metadata); | ||
|
|
||
| PlatformServiceProvider.Instance.SetSourceGeneratedOperations(reflectionOperations, fileOperations); | ||
| } |
| /// <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> |
| 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]; |
| foreach (object attribute in _dataProvider.AssemblyAttributes) | ||
| { | ||
| if (type.IsInstanceOfType(attribute)) | ||
| { | ||
| matches.Add(attribute); | ||
| } | ||
| } |
| foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider)) | ||
| { | ||
| if (attribute is TAttribute) | ||
| { | ||
| return true; | ||
| } | ||
| } |
| foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider)) | ||
| { | ||
| if (attribute is TAttribute typed) | ||
| { | ||
| return typed; | ||
| } | ||
| } |
| 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; | ||
| } | ||
| } |
| foreach (Attribute attribute in GetCustomAttributesCached(attributeProvider)) | ||
| { | ||
| if (attribute is TAttributeType typed) | ||
| { | ||
| yield return typed; | ||
| } | ||
| } |
| 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; |
🔍 Build Failure AnalysisSummary — The build failed due to a missing interface method and enforced code style violations across multiple target frameworks. Root cause 1: Missing interface method
|
| 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 · ◷
Evangelink
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
🔧 CS1061 — SetSourceGeneratedOperations 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:
| PlatformServiceProvider.Instance.SetSourceGeneratedOperations(reflectionOperations, fileOperations); | |
| ((PlatformServiceProvider)PlatformServiceProvider.Instance).SetSourceGeneratedOperations(reflectionOperations, fileOperations); |
| return []; | ||
| } | ||
|
|
||
| if (!_dataProvider.TypeMethodAttributes.TryGetValue(method.DeclaringType, out Dictionary<string, Attribute[]>? methodAttributes)) |
There was a problem hiding this comment.
🔧 IDE0046 — Simplify if statement chain
The dictionary lookup pattern can be simplified:
| 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; |
There was a problem hiding this comment.
🔧 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:
| private static bool s_useSourceGenerator; | |
| public static bool UseSourceGenerator |
| [return: NotNullIfNotNull(nameof(memberInfo))] | ||
| public object[]? GetCustomAttributes(MemberInfo memberInfo) | ||
| { | ||
| if (memberInfo is null) |
There was a problem hiding this comment.
🔧 IDE0046 — Simplify if statement to conditional expression
The null check can be simplified:
| if (memberInfo is null) | |
| return memberInfo is null ? null : GetAttributes(memberInfo); |
| throw new ArgumentNullException(nameof(attributeProvider)); | ||
| } | ||
|
|
||
| if (attributeProvider is not (MemberInfo or Assembly)) |
There was a problem hiding this comment.
🔧 IDE0046 — Simplify nested if statements
The nested pattern matching check can be combined with early returns:
| 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; |
There was a problem hiding this comment.
🔧 IDE0032 — Use auto property instead of explicit backing field
Replace _dataProvider with the field keyword in the property:
| private readonly SourceGeneratedReflectionDataProvider _dataProvider; | |
| public SourceGeneratedReflectionOperations(SourceGeneratedReflectionDataProvider dataProvider) |
| public SourceGeneratedFileOperations(SourceGeneratedReflectionDataProvider dataProvider) | ||
| => _dataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider)); | ||
|
|
||
| internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider; |
There was a problem hiding this comment.
🔧 IDE0032 — Replace DataProvider getter to use field keyword
| internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider; | |
| internal SourceGeneratedReflectionDataProvider DataProvider => field; |
| internal sealed class SourceGeneratedFileOperations : IFileOperations | ||
| { | ||
| private readonly FileOperations _inner = new(); | ||
| private readonly SourceGeneratedReflectionDataProvider _dataProvider; |
There was a problem hiding this comment.
🔧 IDE0032 — Use auto property instead of explicit backing field
Replace _dataProvider with the field keyword in the property:
| private readonly SourceGeneratedReflectionDataProvider _dataProvider; | |
| public SourceGeneratedFileOperations(SourceGeneratedReflectionDataProvider dataProvider) |
| public SourceGeneratedReflectionOperations(SourceGeneratedReflectionDataProvider dataProvider) | ||
| => _dataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider)); | ||
|
|
||
| internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider; |
There was a problem hiding this comment.
🔧 IDE0032 — Replace DataProvider getter to use field keyword
| internal SourceGeneratedReflectionDataProvider DataProvider => _dataProvider; | |
| internal SourceGeneratedReflectionDataProvider DataProvider => field; |
|
|
||
| public static bool UseSourceGenerator | ||
| { | ||
| get => s_useSourceGenerator; |
There was a problem hiding this comment.
🔧 IDE0032 — Replace getter/setter with field keyword
| get => s_useSourceGenerator; | |
| get => field; |
Fixes part of #1837.
What
Introduces a new
MSTestAdapter.PlatformServices.SourceGenerationproject that hosts a RoslynIIncrementalGenerator. The generator discovers[TestClass]types at compile time and emits a[ModuleInitializer]that registers aSourceGeneratedReflectionDataProviderfor the user's assembly.It also adds the runtime infrastructure in
MSTestAdapter.PlatformServicesto swap theIReflectionOperationsandIFileOperationsservices 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
IReflectionOperationsservice 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)
MSTestAdapter.PlatformServices.SourceGenerationwithReflectionMetadataGenerator.[ModuleInitializer]that callsReflectionMetadataHook.SetMetadata.ReflectionMetadataHook,SourceGeneratedReflectionDataProvider,SourceGeneratedReflectionOperations,SourceGeneratedFileOperations,SourceGeneratorToggle.PlatformServiceProvider.SetSourceGeneratedOperations(internal) hook used byReflectionMetadataHookto swap providers.[TestClass]+[TestMethod]), static / abstract classes being skipped, empty assembly emission.TestFx.slnxandMSTest.slnf.PublicAPI.Unshipped.txtupdated.Known follow-ups (out of scope for this PR)
These are intentionally deferred to keep this PR reviewable:
AssemblyAttributes,TypeAttributes,TypeProperties,TypeMethodAttributes,TypeConstructorsInvoker,TypeMethodLocations). The MVP only emits the assembly name, type list, and per-type method list.[ModuleInitializer]polyfill fornetstandard2.0/ older TFM consumers.EnableMSTestSourceGeneration).DataRow/DynamicData/ inherited attributes / base-class roll-up.AssemblyInitialize/ClassInitialize/TestInitializelifecycle.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
IReflectionOperationssignature inmain, and the diff is purely additive plus the two solution-file lines and oneinternalmethod onPlatformServiceProvider.Notes for reviewers
ReflectionMetadataHook.SetMetadataand theSourceGeneratedReflectionDataProvidershape are public, because the generated module initializer needs to call them.SourceGeneratorToggleusesInterlocked.Exchangeto ensure exactly-once swap and to make the toggle race-free if multiple assemblies' module initializers run.ReflectionMetadataGeneratorskipsstaticandabstracttypes — they cannot be discovered as test classes.